Milestone 1.

This commit is contained in:
2025-08-18 20:14:56 +02:00
parent 9d1623c739
commit 1646d7b827
23 changed files with 1684 additions and 1275 deletions

2
.gitignore vendored
View File

@@ -18,3 +18,5 @@ app/static/css/app.css.map
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
.vault/

View File

@@ -1,5 +1,6 @@
from flask import Flask from flask import Flask
from .config import load_config from .config import load_config
from .services.vault import Vault
def create_app(config_override=None) -> Flask: def create_app(config_override=None) -> Flask:
@@ -10,16 +11,24 @@ def create_app(config_override=None) -> Flask:
) )
cfg = config_override or load_config() cfg = config_override or load_config()
# Map to Flask app.config for easy access in templates/routes
app.config["KB_VAULT_PATH"] = cfg.VAULT_PATH app.config["KB_VAULT_PATH"] = cfg.VAULT_PATH
app.config["SECRET_KEY"] = cfg.SECRET_KEY app.config["SECRET_KEY"] = cfg.SECRET_KEY
app.config["DEBUG"] = cfg.DEBUG app.config["DEBUG"] = cfg.DEBUG
# Ensure vault structure early if configured
if cfg.VAULT_PATH:
try:
Vault(cfg.VAULT_PATH).ensure_structure()
except Exception:
# Avoid crashing on startup; routes can surface errors as needed
pass
# Register blueprints # Register blueprints
from .routes.home import bp as home_bp from .routes.home import bp as home_bp
from .routes.notes import bp as notes_bp
app.register_blueprint(home_bp) app.register_blueprint(home_bp)
app.register_blueprint(notes_bp)
# Simple health endpoint
@app.get("/healthz") @app.get("/healthz")
def healthz(): def healthz():
return {"status": "ok", "vault": app.config.get("KB_VAULT_PATH")}, 200 return {"status": "ok", "vault": app.config.get("KB_VAULT_PATH")}, 200

View File

@@ -1,4 +1,4 @@
from flask import Blueprint, current_app, render_template from flask import Blueprint, current_app, render_template, url_for
bp = Blueprint("home", __name__) bp = Blueprint("home", __name__)
@@ -7,4 +7,4 @@ bp = Blueprint("home", __name__)
def home(): def home():
vault_path = current_app.config.get("KB_VAULT_PATH") vault_path = current_app.config.get("KB_VAULT_PATH")
has_vault = bool(vault_path) has_vault = bool(vault_path)
return render_template("home.html", vault_path=vault_path, has_vault=has_vault) return render_template("home.html", vault_path=vault_path, has_vault=has_vault, notes_url=url_for("notes.notes_index"))

50
app/routes/notes.py Normal file
View File

@@ -0,0 +1,50 @@
from __future__ import annotations
from flask import Blueprint, abort, redirect, render_template, request, url_for
from app.services.notes_fs import create_note, list_notes, load_note_by_id, update_note_body
bp = Blueprint("notes", __name__, url_prefix="/notes")
@bp.get("/")
def notes_index():
notes = list_notes()
return render_template("notes/list.html", notes=notes)
@bp.get("/new")
def notes_new():
return render_template("notes/new.html")
@bp.post("/")
def notes_create():
title = (request.form.get("title") or "").strip()
body = (request.form.get("body") or "").strip()
tags_raw = (request.form.get("tags") or "").strip()
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else []
if not title:
return render_template("notes/new.html", error="Title is required", title=title, body=body, tags_raw=tags_raw), 400
note = create_note(title=title, body=body, tags=tags)
return redirect(url_for("notes.notes_view", note_id=note.id))
@bp.get("/<note_id>")
def notes_view(note_id: str):
note = load_note_by_id(note_id)
if not note:
abort(404)
# M2 will render Markdown; for now show raw body and metadata
return render_template("notes/view.html", note=note)
@bp.post("/<note_id>/body")
def notes_update_body(note_id: str):
new_body = request.form.get("body") or ""
note = update_note_body(note_id, new_body)
if not note:
abort(404)
return redirect(url_for("notes.notes_view", note_id=note.id))

231
app/services/notes_fs.py Normal file
View File

@@ -0,0 +1,231 @@
from __future__ import annotations
import json
import os
import uuid
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
import frontmatter
from flask import current_app
from app.services.vault import Vault
from app.utils.paths import atomic_write, ensure_unique_path
from app.utils.slugs import slugify
from app.utils.time import now_iso_utc, is_iso_utc
REQUIRED_FIELDS = ("id", "title", "created", "updated")
OPTIONAL_LIST_FIELDS = ("aliases", "tags")
@dataclass
class Note:
id: str
title: str
created: str
updated: str
slug: str
rel_path: str # relative path from vault root, e.g. notes/foo.md
tags: List[str] = field(default_factory=list)
aliases: List[str] = field(default_factory=list)
status: Optional[str] = None
summary: Optional[str] = None
body: str = ""
def _normalize_metadata(meta: dict) -> dict:
meta = dict(meta or {})
# Lists
for key in OPTIONAL_LIST_FIELDS:
val = meta.get(key, [])
if val is None:
val = []
if isinstance(val, str):
# Allow comma-separated string as input
val = [x.strip() for x in val.split(",") if x.strip()]
if not isinstance(val, list):
val = [str(val)]
meta[key] = val
# Strings
for key in ("title", "status", "summary"):
if key in meta and meta[key] is not None:
meta[key] = str(meta[key])
# UUID
if not meta.get("id"):
meta["id"] = str(uuid.uuid4())
else:
try:
meta["id"] = str(uuid.UUID(str(meta["id"])))
except Exception:
meta["id"] = str(uuid.uuid4())
# Timestamps
created = meta.get("created")
updated = meta.get("updated")
if not created or not is_iso_utc(str(created)):
created = now_iso_utc()
if not updated or not is_iso_utc(str(updated)):
updated = created
meta["created"] = str(created)
meta["updated"] = str(updated)
# Title fallback
if not meta.get("title"):
meta["title"] = "Untitled"
return meta
def _frontmatter_from_note(note: Note) -> dict:
data = {
"id": note.id,
"title": note.title,
"created": note.created,
"updated": note.updated,
}
if note.tags:
data["tags"] = list(note.tags)
if note.aliases:
data["aliases"] = list(note.aliases)
if note.status:
data["status"] = note.status
if note.summary:
data["summary"] = note.summary
return data
def get_vault() -> Vault:
vault_path = current_app.config.get("KB_VAULT_PATH")
if not vault_path:
raise RuntimeError("Vault path is not configured (KB_VAULT_PATH).")
v = Vault(vault_path)
v.ensure_structure()
return v
def note_path_for_slug(vault: Vault, slug: str) -> Tuple[str, str]:
rel = os.path.join("notes", f"{slug}.md")
abs_path = os.path.join(vault.paths.root, rel)
return rel, abs_path
def load_note_from_file(vault: Vault, abs_path: str) -> Note:
with open(abs_path, "rb") as f:
post = frontmatter.load(f)
meta = _normalize_metadata(post.metadata)
title = meta["title"]
slug = slugify(title)
rel, _ = note_path_for_slug(vault, slug)
# Use actual rel path of the file (could differ from current slug if renamed later)
rel = vault.relpath(abs_path)
note = Note(
id=meta["id"],
title=title,
created=meta["created"],
updated=meta["updated"],
slug=slug,
rel_path=rel,
tags=meta.get("tags", []),
aliases=meta.get("aliases", []),
status=meta.get("status"),
summary=meta.get("summary"),
body=post.content or "",
)
return note
def create_note(
title: str,
body: str = "",
tags: Optional[List[str]] = None,
aliases: Optional[List[str]] = None,
status: Optional[str] = None,
summary: Optional[str] = None,
) -> Note:
vault = get_vault()
meta = _normalize_metadata(
{
"title": title,
"tags": tags or [],
"aliases": aliases or [],
"status": status,
"summary": summary,
}
)
slug = slugify(meta["title"])
rel, abs_path = note_path_for_slug(vault, slug)
abs_path = ensure_unique_path(abs_path)
# If collision caused a suffix, recompute rel path accordingly
rel = vault.relpath(abs_path)
note = Note(
id=meta["id"],
title=meta["title"],
created=meta["created"],
updated=meta["updated"],
slug=slug,
rel_path=rel,
tags=meta.get("tags", []),
aliases=meta.get("aliases", []),
status=meta.get("status"),
summary=meta.get("summary"),
body=body or "",
)
save_note(vault, note)
return note
def save_note(vault: Vault, note: Note) -> None:
data = _frontmatter_from_note(note)
post = frontmatter.Post(note.body, **data)
text = frontmatter.dumps(post)
atomic_write(vault.abspath(note.rel_path), text.encode("utf-8"))
def list_notes() -> list[Note]:
vault = get_vault()
notes: list[Note] = []
for path in vault.iter_markdown_files():
try:
notes.append(load_note_from_file(vault, path))
except Exception:
# Ignore malformed files for now (could log)
continue
# Sort by updated desc
notes.sort(key=lambda n: n.updated, reverse=True)
return notes
def load_note_by_id(note_id: str) -> Optional[Note]:
vault = get_vault()
for path in vault.iter_markdown_files():
try:
with open(path, "rb") as f:
post = frontmatter.load(f)
meta = _normalize_metadata(post.metadata)
if str(meta.get("id")) == str(note_id):
# Load fully to construct Note with body and rel path
return load_note_from_file(vault, path)
except Exception:
continue
return None
def update_note_body(note_id: str, new_body: str) -> Optional[Note]:
note = load_note_by_id(note_id)
if not note:
return None
note.body = new_body
# Update timestamp
note.updated = now_iso_utc()
vault = get_vault()
save_note(vault, note)
return note

45
app/services/vault.py Normal file
View File

@@ -0,0 +1,45 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Iterable
@dataclass(frozen=True)
class VaultPaths:
root: str
notes: str
attachments: str
kb: str
class Vault:
def __init__(self, root_path: str):
if not root_path:
raise ValueError("Vault root path must be provided")
self.root_path = os.path.abspath(root_path)
self.paths = VaultPaths(
root=self.root_path,
notes=os.path.join(self.root_path, "notes"),
attachments=os.path.join(self.root_path, "attachments"),
kb=os.path.join(self.root_path, ".kb"),
)
def ensure_structure(self) -> None:
os.makedirs(self.paths.root, exist_ok=True)
os.makedirs(self.paths.notes, exist_ok=True)
os.makedirs(self.paths.attachments, exist_ok=True)
os.makedirs(self.paths.kb, exist_ok=True)
def relpath(self, abs_path: str) -> str:
return os.path.relpath(abs_path, self.paths.root)
def abspath(self, rel_path: str) -> str:
return os.path.join(self.paths.root, rel_path)
def iter_markdown_files(self) -> Iterable[str]:
"""Yield absolute file paths for all .md files under notes/ recursively."""
for dirpath, _, filenames in os.walk(self.paths.notes):
for fn in filenames:
if fn.lower().endswith(".md"):
yield os.path.join(dirpath, fn)

View File

@@ -1,5 +1,2 @@
@tailwind base; @import "tailwindcss";
@tailwind components; @plugin "daisyui";
@tailwind utilities;
/* You can add custom CSS here if needed */

View File

@@ -7,7 +7,11 @@
{% if has_vault %} {% if has_vault %}
<p>Your vault path is configured:</p> <p>Your vault path is configured:</p>
<code class="kbd kbd-sm">{{ vault_path }}</code> <code class="kbd kbd-sm">{{ vault_path }}</code>
<p class="mt-2">You're ready to proceed with indexing and note features in the next milestones.</p> <div class="mt-3">
<a href="{{ notes_url }}" class="btn btn-primary btn-sm">Open Notes</a>
<a href="{{ url_for('notes.notes_new') }}" class="btn btn-outline btn-sm ml-2">New Note</a>
</div>
<p class="mt-4">You're ready to proceed with indexing and note features in the next milestones.</p>
{% else %} {% else %}
<div class="alert alert-warning"> <div class="alert alert-warning">
<span>No vault path configured.</span> <span>No vault path configured.</span>

View File

@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Notes — PKM{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-semibold">Notes</h1>
<a href="{{ url_for('notes.notes_new') }}" class="btn btn-primary btn-sm">New note</a>
</div>
{% if notes %}
<div class="space-y-2">
{% for n in notes %}
<a href="{{ url_for('notes.notes_view', note_id=n.id) }}" class="block card bg-base-100 border hover:shadow">
<div class="card-body py-3">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium">{{ n.title }}</span>
<span class="badge badge-ghost">{{ n.updated }}</span>
</div>
{% if n.tags %}
<div class="flex gap-1 mt-1">
{% for t in n.tags %}
<span class="badge badge-outline">{{ t }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="alert">
<span>No notes yet. Create your first one.</span>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}New Note — PKM{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto">
<h1 class="text-2xl font-semibold mb-4">New Note</h1>
{% if error %}
<div class="alert alert-error mb-4">
<span>{{ error }}</span>
</div>
{% endif %}
<form method="post" action="{{ url_for('notes.notes_create') }}" class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text">Title</span>
</label>
<input name="title" value="{{ title or '' }}" class="input input-bordered" placeholder="Note title" required />
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Body (Markdown)</span>
</label>
<textarea name="body" rows="10" class="textarea textarea-bordered" placeholder="# Heading...">{{ body or '' }}</textarea>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Tags (comma-separated)</span>
</label>
<input name="tags" value="{{ tags_raw or '' }}" class="input input-bordered" placeholder="tag1, tag2" />
</div>
<div class="flex gap-2">
<button class="btn btn-primary" type="submit">Create</button>
<a class="btn btn-ghost" href="{{ url_for('notes.notes_index') }}">Cancel</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}{{ note.title }} — PKM{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-semibold">{{ note.title }}</h1>
<a class="btn btn-sm" href="{{ url_for('notes.notes_index') }}">All notes</a>
</div>
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm opacity-80">
<span>ID: <code class="kbd kbd-sm">{{ note.id }}</code></span>
<span>Created: {{ note.created }}</span>
<span>Updated: {{ note.updated }}</span>
{% if note.status %}<span class="badge badge-outline">{{ note.status }}</span>{% endif %}
{% if note.tags %}
<span class="ml-2">Tags:</span>
<div class="flex gap-1">
{% for t in note.tags %}
<span class="badge badge-ghost">{{ t }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="mt-4">
<h2 class="text-lg font-medium mb-2">Body (raw Markdown)</h2>
<pre class="p-4 bg-base-200 rounded overflow-x-auto whitespace-pre-wrap">{{ note.body }}</pre>
</div>
<div class="mt-6">
<h3 class="font-medium mb-2">Quick edit</h3>
<form method="post" action="{{ url_for('notes.notes_update_body', note_id=note.id) }}">
<textarea name="body" rows="10" class="textarea textarea-bordered w-full">{{ note.body }}</textarea>
<div class="mt-2">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
</div>
</form>
</div>
<div class="mt-6 opacity-70 text-sm">
<div>File: <code class="kbd kbd-sm">{{ note.rel_path }}</code></div>
<div>Slug: <code class="kbd kbd-sm">{{ note.slug }}</code></div>
</div>
</div>
{% endblock %}

40
app/utils/paths.py Normal file
View File

@@ -0,0 +1,40 @@
from __future__ import annotations
import os
import tempfile
from typing import Optional
def atomic_write(path: str, data: bytes, mode: int = 0o644) -> None:
"""
Atomically write bytes to path by writing to a temp file in the same dir and then replacing.
"""
directory = os.path.dirname(path)
os.makedirs(directory, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=".tmp-", dir=directory)
try:
with os.fdopen(fd, "wb") as tmp_file:
tmp_file.write(data)
os.chmod(tmp_path, mode)
os.replace(tmp_path, path)
finally:
try:
if os.path.exists(tmp_path):
os.remove(tmp_path)
except Exception:
pass
def ensure_unique_path(path: str) -> str:
"""
If path exists, append -2, -3, ... before extension to avoid collision.
"""
if not os.path.exists(path):
return path
base, ext = os.path.splitext(path)
i = 2
while True:
candidate = f"{base}-{i}{ext}"
if not os.path.exists(candidate):
return candidate
i += 1

45
app/utils/slugs.py Normal file
View File

@@ -0,0 +1,45 @@
import re
import unicodedata
def slugify(title: str, max_length: int = 80) -> str:
"""
Convert a title to a filesystem-friendly slug:
- normalize unicode to NFKD and strip accents
- lowercase
- replace non alphanum with hyphens
- collapse duplicate hyphens
- trim hyphens at edges
- limit length (keeping whole segments if possible)
"""
if not title:
return "untitled"
# Normalize accents
normalized = unicodedata.normalize("NFKD", title)
ascii_str = normalized.encode("ascii", "ignore").decode("ascii")
# Lowercase and replace non-alphanum with hyphen
slug = re.sub(r"[^a-z0-9]+", "-", ascii_str.lower()).strip("-")
if not slug:
slug = "untitled"
# Collapse multiple hyphens
slug = re.sub(r"-{2,}", "-", slug)
# Respect max length, try not to cut segments
if len(slug) > max_length:
parts = slug.split("-")
cut = []
total = 0
for p in parts:
# +1 for the hyphen when joining (except first)
add = len(p) + (1 if cut else 0)
if total + add > max_length:
break
cut.append(p)
total += add
slug = "-".join(cut) if cut else slug[:max_length].rstrip("-")
return slug

19
app/utils/time.py Normal file
View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from datetime import datetime, timezone
def now_iso_utc() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def is_iso_utc(value: str) -> bool:
try:
# Accept the 'Z' suffix
if value.endswith("Z"):
datetime.fromisoformat(value.replace("Z", "+00:00"))
else:
datetime.fromisoformat(value)
return True
except Exception:
return False

91
cli.py
View File

@@ -1,7 +1,12 @@
import os import os
import sys
import click import click
import yaml
from app import create_app from app import create_app
from app.config import load_config from app.config import load_config
from app.services.vault import Vault
from app.utils.time import now_iso_utc
@click.group() @click.group()
@@ -17,9 +22,7 @@ def main():
@click.option("--debug/--no-debug", default=True, show_default=True, help="Enable/disable debug mode") @click.option("--debug/--no-debug", default=True, show_default=True, help="Enable/disable debug mode")
def runserver(vault_path: str | None, host: str, port: int, debug: bool): def runserver(vault_path: str | None, host: str, port: int, debug: bool):
"""Run the development server.""" """Run the development server."""
# Prefer CLI vault over env
cfg = load_config(vault_override=vault_path) cfg = load_config(vault_override=vault_path)
# Mirror into environment for consistency if needed
if cfg.VAULT_PATH: if cfg.VAULT_PATH:
os.environ["KB_VAULT_PATH"] = cfg.VAULT_PATH os.environ["KB_VAULT_PATH"] = cfg.VAULT_PATH
os.environ["FLASK_DEBUG"] = "1" if debug else "0" os.environ["FLASK_DEBUG"] = "1" if debug else "0"
@@ -28,5 +31,89 @@ def runserver(vault_path: str | None, host: str, port: int, debug: bool):
app.run(host=host, port=port, debug=debug) app.run(host=host, port=port, debug=debug)
@main.command("init")
@click.option(
"--vault",
"vault_path",
required=True,
type=click.Path(file_okay=False, dir_okay=True, path_type=str),
help="Path to the vault directory to initialize",
)
@click.option(
"--sample/--no-sample",
default=True,
show_default=True,
help="Create a sample welcome note",
)
@click.option(
"--force",
is_flag=True,
default=False,
help="Proceed even if the directory is not empty",
)
def init_vault(vault_path: str, sample: bool, force: bool):
"""
Initialize a vault directory with notes/, attachments/, and .kb/.
Optionally create a sample note and a .kb/config.yml file.
"""
vault_path = os.path.abspath(vault_path)
exists = os.path.exists(vault_path)
if exists:
# Check emptiness only if directory exists
try:
entries = [e for e in os.listdir(vault_path) if not e.startswith(".")]
except FileNotFoundError:
entries = []
if entries and not force:
click.echo(
click.style(
f"Directory '{vault_path}' is not empty. Use --force to proceed.",
fg="yellow",
)
)
sys.exit(1)
else:
os.makedirs(vault_path, exist_ok=True)
v = Vault(vault_path)
v.ensure_structure()
# Write .kb/config.yml if it doesn't exist
kb_cfg_path = os.path.join(v.paths.kb, "config.yml")
if not os.path.exists(kb_cfg_path):
cfg_data = {
"version": 1,
"created": now_iso_utc(),
}
with open(kb_cfg_path, "w", encoding="utf-8") as f:
yaml.safe_dump(cfg_data, f, sort_keys=True)
click.echo(click.style(f"Created {kb_cfg_path}", fg="green"))
else:
click.echo(click.style(f"Exists {kb_cfg_path}", fg="blue"))
# Optionally create a sample note using the app context (reuses note writer)
if sample:
from app.services.notes_fs import create_note # lazy import to avoid app deps if not needed
# Build a minimal app context so create_note can resolve the vault
cfg = load_config(vault_override=vault_path)
app = create_app(cfg)
with app.app_context():
# Try to avoid duplicates by checking for an existing welcome file name
title = "Welcome to your vault"
body = (
"# Welcome to your vault\n\n"
"This is your first note. You can edit or delete it.\n\n"
"- Notes live under `notes/`\n"
"- Attachments go to `attachments/`\n"
"- App internals go to `.kb/`\n"
)
note = create_note(title=title, body=body, tags=["welcome"])
click.echo(click.style(f"Created sample note at {os.path.join(v.paths.root, note.rel_path)}", fg="green"))
click.echo(click.style(f"Vault initialized at {vault_path}", fg="green"))
if __name__ == "__main__": if __name__ == "__main__":
main() main()

2169
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,15 @@
{ {
"name": "pkm-frontend", "name": "pkm-frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.5",
"description": "Tailwind + DaisyUI build for PKM", "description": "Tailwind + DaisyUI build for PKM",
"scripts": { "scripts": {
"build:css": "tailwindcss -c tailwind.config.cjs -i ./app/static/css/input.css -o ./app/static/css/app.css --minify", "build:css": "npx @tailwindcss/cli -i ./app/static/css/input.css -o ./app/static/css/app.css --minify",
"watch:css": "tailwindcss -c tailwind.config.cjs -i ./app/static/css/input.css -o ./app/static/css/app.css --watch" "watch:css": "npx @tailwindcss/cli -i ./app/static/css/input.css -o ./app/static/css/app.css --watch"
}, },
"devDependencies": { "devDependencies": {
"daisyui": "^4.12.10", "@tailwindcss/cli": "^4.1.12",
"tailwindcss": "^3.4.10" "daisyui": "^5.0.50",
"tailwindcss": "^4.1.12"
} }
} }

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "pkm" name = "pkm"
version = "0.1.0" version = "0.1.1"
description = "Personal Knowledge Manager (Flask + Tailwind + DaisyUI)" description = "Personal Knowledge Manager (Flask + Tailwind + DaisyUI)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
@@ -12,10 +12,14 @@ dependencies = [
"Flask>=3.0", "Flask>=3.0",
"python-dotenv>=1.0", "python-dotenv>=1.0",
"click>=8.1", "click>=8.1",
"python-frontmatter>=1.0.1",
"PyYAML>=6.0.2",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [] dev = [
"pytest>=8.2",
]
[project.scripts] [project.scripts]
pkm = "cli:main" pkm = "cli:main"

View File

@@ -1,3 +1,5 @@
Flask>=3.0 Flask>=3.0
python-dotenv>=1.0 python-dotenv>=1.0
click>=8.1 click>=8.1
python-frontmatter>=1.0.1
PyYAML>=6.0.2

View File

@@ -1,14 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/templates/**/*.html",
"./app/static/js/**/*.js",
],
theme: {
extend: {},
},
plugins: [require("daisyui")],
daisyui: {
themes: ["light", "dark", "cupcake"],
},
};

17
tailwind.config.js Normal file
View File

@@ -0,0 +1,17 @@
/** @type {import('tailwindcss').Config} */
import daisyui from "daisyui";
export default {
// Make sure all files that contain Tailwind/DaisyUI classes are included here
content: [
"./app/templates/**/*.html",
"./app/static/js/**/*.js"
],
theme: {
extend: {}
},
plugins: [daisyui],
daisyui: {
themes: ["light", "dark", "cupcake"]
}
};

41
tests/test_notes_fs.py Normal file
View File

@@ -0,0 +1,41 @@
import os
import uuid
from app.services.notes_fs import create_note, list_notes, load_note_by_id
from app.utils.time import is_iso_utc
from app import create_app
def test_create_and_load_note(tmp_path, monkeypatch):
vault = tmp_path / "vault"
os.makedirs(vault, exist_ok=True)
# Configure app with temporary vault
app = create_app(type("Cfg", (), {"VAULT_PATH": str(vault), "SECRET_KEY": "x", "DEBUG": False})())
with app.app_context():
n = create_note(title="My Test Note", body="Body", tags=["t1", "t2"])
assert n.title == "My Test Note"
assert is_iso_utc(n.created)
assert is_iso_utc(n.updated)
# File exists
assert (vault / n.rel_path).exists()
# List and find by id
notes = list_notes()
assert any(x.id == n.id for x in notes)
n2 = load_note_by_id(n.id)
assert n2 is not None
assert n2.title == "My Test Note"
assert n2.body == "Body"
assert n2.tags == ["t1", "t2"]
def test_load_note_by_id_missing(tmp_path):
vault = tmp_path / "vault"
os.makedirs(vault, exist_ok=True)
app = create_app(type("Cfg", (), {"VAULT_PATH": str(vault), "SECRET_KEY": "x", "DEBUG": False})())
with app.app_context():
assert load_note_by_id(str(uuid.uuid4())) is None

20
tests/test_slugs.py Normal file
View File

@@ -0,0 +1,20 @@
from app.utils.slugs import slugify
def test_slugify_basic():
assert slugify("Hello World") == "hello-world"
assert slugify("Hello World!!!") == "hello-world"
assert slugify("Café au lait") == "cafe-au-lait"
assert slugify("123 Numbers First") == "123-numbers-first"
def test_slugify_length():
long = "A" * 200
s = slugify(long, max_length=32)
assert len(s) <= 32
assert s.startswith("a")
def test_slugify_empty():
assert slugify("") == "untitled"
assert slugify("!!!") == "untitled"