Milestone 1.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,3 +18,5 @@ app/static/css/app.css.map
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
.vault/
|
||||||
|
@@ -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
|
||||||
|
@@ -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
50
app/routes/notes.py
Normal 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
231
app/services/notes_fs.py
Normal 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
45
app/services/vault.py
Normal 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)
|
@@ -1,5 +1,2 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
@plugin "daisyui";
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
/* You can add custom CSS here if needed */
|
|
@@ -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>
|
||||||
|
34
app/templates/notes/list.html
Normal file
34
app/templates/notes/list.html
Normal 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 %}
|
41
app/templates/notes/new.html
Normal file
41
app/templates/notes/new.html
Normal 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 %}
|
45
app/templates/notes/view.html
Normal file
45
app/templates/notes/view.html
Normal 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
40
app/utils/paths.py
Normal 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
45
app/utils/slugs.py
Normal 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
19
app/utils/time.py
Normal 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
91
cli.py
@@ -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
2169
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -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"
|
@@ -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
|
@@ -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
17
tailwind.config.js
Normal 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
41
tests/test_notes_fs.py
Normal 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
20
tests/test_slugs.py
Normal 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"
|
Reference in New Issue
Block a user