Milestone 2.

This commit is contained in:
2025-08-18 21:40:41 +02:00
parent 1646d7b827
commit e283e9f696
13 changed files with 615 additions and 107 deletions

1
.gitignore vendored
View File

@@ -20,3 +20,4 @@ app/static/css/app.css.map
Thumbs.db Thumbs.db
.vault/ .vault/
vault/

230
README.md
View File

@@ -1,79 +1,231 @@
# PKM — Milestone 0 (Project Scaffolding) # PKM — Flask + Tailwind (v4) + DaisyUI (v5)
Minimal Flask app with Tailwind + DaisyUI and a welcome page that reads the vault path from configuration. Personal Knowledge Manager with file-first storage (Markdown + YAML front matter), a minimal Flask backend, and a clean Tailwind + DaisyUI UI.
Current milestone: M2 (Rendering and viewing)
- Filesystem vault with `notes/`, `attachments/`, and `.kb/`
- Create and list notes (M1)
- Render Markdown safely with plugins and sanitization (M2)
- Obsidian-compatible [[WikiLinks]] (`[[Note]]`, `[[Note|Alias]]`, `[[Note#Heading]]`)
- Attachment serving from `/attachments/...`
Upcoming (M3)
- CodeMirror 6 editor, front matter form, paste/drag image upload
---
## Prerequisites ## Prerequisites
- Python 3.9+ - Python 3.9+
- Node.js 18+ (for Tailwind CLI) - Node.js 18+
- SQLite (system-provided; FTS5 required later, not needed in M0) - SQLite (FTS5 needed later in M4; not required yet)
## Setup ---
## Setup (Python)
Create and activate a virtual environment:
### 1) Python environment
```bash ```bash
python -m venv .venv python3 -m venv .venv
# macOS/Linux # macOS/Linux
source .venv/bin/activate source .venv/bin/activate
# Windows PowerShell # Windows PowerShell
# .venv\\Scripts\\Activate.ps1 # .venv\Scripts\Activate.ps1
pip install -r requirements.txt
``` ```
Optionally install as a package: Install Python dependencies:
```bash ```bash
pip install -e . python3 -m pip install -r requirements.txt
# or, if you prefer editable install:
python3 -m pip install -e .
``` ```
### 2) Node dependencies and CSS build (Optional) For tests:
```bash
python3 -m pip install -e .[dev]
```
---
## Setup (CSS build with Tailwind v4 + DaisyUI v5)
Install Node dev dependencies (uses the Tailwind v4 CLI package):
```bash ```bash
npm install npm install
# One-time build ```
Build CSS once:
```bash
npm run build:css npm run build:css
# Or during development (in a separate terminal), watch for changes: ```
Or keep a watcher running during development:
```bash
npm run watch:css npm run watch:css
``` ```
### 3) Configure vault path This repository uses:
Create a `.env` from the example and set your vault directory: - `tailwind.config.js` (ESM) with DaisyUI plugin
- `app/static/css/input.css` with:
- `@import "tailwindcss";`
- `@plugin "daisyui";`
If the page looks unstyled, see Troubleshooting below.
---
## Configure your vault
You can configure the vault in either of two ways:
1) Environment file
- Copy `.env.example` to `.env` and set `KB_VAULT_PATH` to an absolute path.
- You can also set `SECRET_KEY` and `FLASK_DEBUG`.
```bash ```bash
cp .env.example .env cp .env.example .env
# edit .env and set KB_VAULT_PATH # edit .env to set KB_VAULT_PATH=/absolute/path/to/your/vault
``` ```
Alternatively, pass the vault path via the CLI when starting the server. 2) CLI flags
- Pass `--vault /path/to/vault` when running the app (takes precedence over env).
---
## Initialize a vault (optional but recommended)
This creates the standard layout and a sample “Welcome” note:
```bash
python3 cli.py init --vault /absolute/path/to/vault
# Options:
# --no-sample Skip creating the sample note
# --force Proceed even if the directory is not empty
```
The vault structure will be:
```
<vault>/
├─ notes/
├─ attachments/
└─ .kb/
├─ config.yml
└─ (index.sqlite will be added in M4)
```
---
## Run the app ## Run the app
Option A — via Flask CLI (loads `create_app` automatically): Using the provided CLI (explicit python3):
```bash ```bash
# Ensure .env is present or set KB_VAULT_PATH in env python3 cli.py run --vault /absolute/path/to/vault
flask --app app run --debug # Options:
# --host 127.0.0.1
# --port 5000
# --debug/--no-debug
``` ```
Option B — via provided CLI: Or, if you set `KB_VAULT_PATH` in `.env`:
```bash ```bash
# Using .env python3 cli.py run
python cli.py run
# Or pass vault explicitly
python cli.py run --vault /path/to/vault
``` ```
Then open: Open the app at:
``` ```
http://127.0.0.1:5000/ http://127.0.0.1:5000/
``` ```
You should see: You can:
- A welcome card with your configured vault path (or a warning if not set). - Create a note at “New Note”
- Tailwind + DaisyUI styles active. - View “Notes” to list them
- A theme selector (light/dark/cupcake) in the navbar. - Use `[[WikiLinks]]` between notes
- Reference attachments via `attachments/<filename>` (served from `/attachments/...`)
## Development tips ---
- Run `npm run watch:css` while you work on templates for live CSS rebuilds.
- If you rename template paths, update `tailwind.config.cjs` content globs.
- Keep `app/static/css/app.css` out of git (its built output).
## Next steps (Milestone 1+) ## Tests
- Implement note model, vault FS access, and basic CRUD.
- Add markdown rendering, sanitization, and set up the SQLite schema for indexing. ```bash
python3 -m pip install -e .[dev]
python3 -m pytest
```
---
## Troubleshooting (Tailwind/DaisyUI v4)
Symptoms: CSS is loaded but the page is unstyled or missing DaisyUI components.
Checklist:
- Ensure Tailwind v4 CLI is used via npm scripts:
- `npm run build:css` (uses `npx @tailwindcss/cli` under the hood as configured)
- Confirm ESM config is in place and unique:
- Keep only `tailwind.config.js` (remove any `tailwind.config.cjs`)
- Confirm input CSS contains:
- `@import "tailwindcss";`
- `@plugin "daisyui";`
- Verify template scan globs match your files:
- `content: ["./app/templates/**/*.html", "./app/static/js/**/*.js"]`
- Rebuild:
- `rm -f app/static/css/app.css && npm run build:css`
- Check built CSS for DaisyUI classes:
- `grep -n "btn" app/static/css/app.css`
If your shell resolves a Ruby `tailwindcss` gem, always invoke via npm scripts (or `npx @tailwindcss/cli`) to ensure the Node CLI runs.
---
## Security notes
- Rendered HTML is sanitized with `bleach`; external links are made safe.
- Attachments are served with path traversal protection.
- For local use, no authentication is enabled yet (planned later if remote hosting is needed).
---
## Project structure (high level)
```
app/
__init__.py # Flask app factory
config.py # Env/.env config
routes/
home.py # Welcome page
notes.py # Notes list/view/create
attachments.py # Safe serving of attachments
services/
vault.py # Vault paths and structure
notes_fs.py # Markdown + YAML read/write
renderer.py # Markdown rendering + sanitization + wikilinks
utils/
slugs.py # Slug generation
time.py # ISO timestamp helpers
paths.py # Atomic write & path utilities
templates/
... # Jinja templates (DaisyUI)
static/
css/input.css # Tailwind v4 entry (import + plugin)
css/app.css # Built output (gitignored)
js/app.js # Theme toggle, small helpers
cli.py # CLI: init vault, run server
```
---
## Roadmap
- M3: CodeMirror 6 editor, metadata edit form, image paste/drag to `attachments/`.
- M4: Indexing/search with SQLite FTS5 under `<vault>/.kb/index.sqlite`.
- Later: Renames + link rewrites, Git history, command palette, etc.
---

View File

@@ -15,19 +15,20 @@ def create_app(config_override=None) -> Flask:
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: if cfg.VAULT_PATH:
try: try:
Vault(cfg.VAULT_PATH).ensure_structure() Vault(cfg.VAULT_PATH).ensure_structure()
except Exception: except Exception:
# Avoid crashing on startup; routes can surface errors as needed
pass 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 from .routes.notes import bp as notes_bp
from .routes.attachments import bp as attachments_bp # ensure attachments is registered
app.register_blueprint(home_bp) app.register_blueprint(home_bp)
app.register_blueprint(notes_bp) app.register_blueprint(notes_bp)
app.register_blueprint(attachments_bp)
@app.get("/healthz") @app.get("/healthz")
def healthz(): def healthz():

32
app/routes/attachments.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import os
from pathlib import Path
from flask import Blueprint, abort, current_app, send_from_directory
from app.services.vault import Vault
bp = Blueprint("attachments", __name__, url_prefix="/attachments")
@bp.get("/<path:subpath>")
def serve_attachment(subpath: str):
# Resolve within the vault attachments directory
vault_path = current_app.config.get("KB_VAULT_PATH")
if not vault_path:
abort(404)
v = Vault(vault_path)
attachments_dir = Path(v.paths.attachments).resolve()
requested = (attachments_dir / subpath).resolve()
# Prevent path traversal
try:
requested.relative_to(attachments_dir)
except Exception:
abort(403)
if not requested.exists() or not requested.is_file():
abort(404)
return send_from_directory(attachments_dir, os.path.relpath(str(requested), str(attachments_dir)))

View File

@@ -1,8 +1,12 @@
from __future__ import annotations from __future__ import annotations
from flask import Blueprint, abort, redirect, render_template, request, url_for from flask import Blueprint, abort, redirect, render_template, request, url_for, jsonify, current_app
from app.services.notes_fs import create_note, list_notes, load_note_by_id, update_note_body from app.services.notes_fs import create_note, list_notes, load_note_by_id, update_note_body
from app.services.renderer import render_markdown
from app.services.vault import Vault
import frontmatter
import os
bp = Blueprint("notes", __name__, url_prefix="/notes") bp = Blueprint("notes", __name__, url_prefix="/notes")
@@ -37,8 +41,17 @@ def notes_view(note_id: str):
note = load_note_by_id(note_id) note = load_note_by_id(note_id)
if not note: if not note:
abort(404) abort(404)
# M2 will render Markdown; for now show raw body and metadata
return render_template("notes/view.html", note=note) all_notes = list_notes()
rendered = render_markdown(note.body or "", all_notes=all_notes)
return render_template(
"notes/view.html",
note=note,
rendered_html=rendered["html"],
unresolved_wikilinks=rendered["unresolved_wikilinks"],
outbound_note_ids=rendered["outbound_note_ids"],
)
@bp.post("/<note_id>/body") @bp.post("/<note_id>/body")
@@ -48,3 +61,59 @@ def notes_update_body(note_id: str):
if not note: if not note:
abort(404) abort(404)
return redirect(url_for("notes.notes_view", note_id=note.id)) return redirect(url_for("notes.notes_view", note_id=note.id))
@bp.get("/_debug/ids")
def notes_debug_ids():
notes = list_notes()
return jsonify(
notes=[
{"id": n.id, "title": n.title, "path": n.rel_path}
for n in notes]
)
@bp.get("/_debug/scan")
def notes_debug_scan():
vault_path = current_app.config.get("KB_VAULT_PATH")
v = Vault(vault_path)
v.ensure_structure()
notes_dir = v.paths.notes
exists = os.path.isdir(notes_dir)
ls_notes = []
if exists:
try:
ls_notes = sorted(os.listdir(notes_dir))
except Exception as e:
ls_notes = [f"<error listing dir: {e}>"]
discovered = list(v.iter_markdown_files())
probe = []
for p in discovered:
try:
with open(p, "r", encoding="utf-8", errors="replace") as f:
post = frontmatter.loads(f.read())
meta = post.metadata or {}
probe.append({
"path": p,
"ok": True,
"id": meta.get("id"),
"title": meta.get("title"),
})
except Exception as e:
probe.append({
"path": p,
"ok": False,
"error": str(e),
})
return jsonify({
"vault": vault_path,
"notes_dir": notes_dir,
"notes_dir_exists": exists,
"notes_dir_list": ls_notes,
"discovered_md_files": discovered,
"probe": probe,
})

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json
import os import os
import re
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
@@ -15,7 +15,6 @@ from app.utils.slugs import slugify
from app.utils.time import now_iso_utc, is_iso_utc from app.utils.time import now_iso_utc, is_iso_utc
REQUIRED_FIELDS = ("id", "title", "created", "updated")
OPTIONAL_LIST_FIELDS = ("aliases", "tags") OPTIONAL_LIST_FIELDS = ("aliases", "tags")
@@ -42,7 +41,6 @@ def _normalize_metadata(meta: dict) -> dict:
if val is None: if val is None:
val = [] val = []
if isinstance(val, str): if isinstance(val, str):
# Allow comma-separated string as input
val = [x.strip() for x in val.split(",") if x.strip()] val = [x.strip() for x in val.split(",") if x.strip()]
if not isinstance(val, list): if not isinstance(val, list):
val = [str(val)] val = [str(val)]
@@ -112,15 +110,17 @@ def note_path_for_slug(vault: Vault, slug: str) -> Tuple[str, str]:
return rel, abs_path return rel, abs_path
def load_note_from_file(vault: Vault, abs_path: str) -> Note: def _load_frontmatter_text(abs_path: str):
with open(abs_path, "rb") as f: # Always read as text for python-frontmatter
post = frontmatter.load(f) with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
return frontmatter.loads(f.read())
def load_note_from_file(vault: Vault, abs_path: str) -> Note:
post = _load_frontmatter_text(abs_path)
meta = _normalize_metadata(post.metadata) meta = _normalize_metadata(post.metadata)
title = meta["title"] title = meta["title"]
slug = slugify(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) rel = vault.relpath(abs_path)
note = Note( note = Note(
@@ -161,8 +161,6 @@ def create_note(
slug = slugify(meta["title"]) slug = slugify(meta["title"])
rel, abs_path = note_path_for_slug(vault, slug) rel, abs_path = note_path_for_slug(vault, slug)
abs_path = ensure_unique_path(abs_path) abs_path = ensure_unique_path(abs_path)
# If collision caused a suffix, recompute rel path accordingly
rel = vault.relpath(abs_path) rel = vault.relpath(abs_path)
note = Note( note = Note(
@@ -193,29 +191,46 @@ def save_note(vault: Vault, note: Note) -> None:
def list_notes() -> list[Note]: def list_notes() -> list[Note]:
vault = get_vault() vault = get_vault()
notes: list[Note] = [] notes: list[Note] = []
for path in vault.iter_markdown_files(): for p in vault.iter_markdown_files():
try: try:
notes.append(load_note_from_file(vault, path)) abs_path = p if os.path.isabs(p) else vault.abspath(p)
notes.append(load_note_from_file(vault, abs_path))
except Exception: except Exception:
# Ignore malformed files for now (could log)
continue continue
# Sort by updated desc
notes.sort(key=lambda n: n.updated, reverse=True) notes.sort(key=lambda n: n.updated, reverse=True)
return notes return notes
_hex_re = re.compile(r"[0-9a-fA-F]")
def _uuid_key(value: str) -> Optional[str]:
if value is None:
return None
s = "".join(_hex_re.findall(str(value)))
if len(s) == 32:
return s.lower()
try:
return uuid.UUID(str(value)).hex
except Exception:
return None
def load_note_by_id(note_id: str) -> Optional[Note]: def load_note_by_id(note_id: str) -> Optional[Note]:
vault = get_vault() vault = get_vault()
for path in vault.iter_markdown_files(): target_key = _uuid_key(note_id)
if not target_key:
return None
for p in vault.iter_markdown_files():
try: try:
with open(path, "rb") as f: abs_path = p if os.path.isabs(p) else vault.abspath(p)
post = frontmatter.load(f) post = _load_frontmatter_text(abs_path)
meta = _normalize_metadata(post.metadata) raw_id = (post.metadata or {}).get("id")
if str(meta.get("id")) == str(note_id): cand_key = _uuid_key(raw_id)
# Load fully to construct Note with body and rel path if cand_key and cand_key == target_key:
return load_note_from_file(vault, path) return load_note_from_file(vault, abs_path)
except Exception: except Exception:
continue continue
return None return None
@@ -224,7 +239,6 @@ def update_note_body(note_id: str, new_body: str) -> Optional[Note]:
if not note: if not note:
return None return None
note.body = new_body note.body = new_body
# Update timestamp
note.updated = now_iso_utc() note.updated = now_iso_utc()
vault = get_vault() vault = get_vault()
save_note(vault, note) save_note(vault, note)

212
app/services/renderer.py Normal file
View File

@@ -0,0 +1,212 @@
from __future__ import annotations
import re
from html import escape
from typing import Dict, Iterable, List, Optional, Tuple
import bleach
from markdown_it import MarkdownIt
from mdit_py_plugins.footnote import footnote_plugin
from mdit_py_plugins.tasklists import tasklists_plugin
from mdit_py_plugins.deflist import deflist_plugin
from mdit_py_plugins.admon import admon_plugin
from mdit_py_plugins.anchors import anchors_plugin
from flask import url_for
from app.utils.slugs import slugify
# Build a MarkdownIt renderer with useful plugins
_md = (
MarkdownIt("commonmark", {"linkify": True})
.use(footnote_plugin)
.use(tasklists_plugin, enabled=True, label=True)
.use(deflist_plugin)
.use(admon_plugin)
# Use only supported options here to avoid version mismatches
.use(anchors_plugin, permalink=True, max_level=6, slug_func=slugify)
)
# Bleach sanitization configuration
ALLOWED_TAGS = [
# Basic text
"p", "div", "span", "br", "hr", "blockquote", "pre", "code",
# Headings
"h1", "h2", "h3", "h4", "h5", "h6",
# Lists
"ul", "ol", "li", "dl", "dt", "dd",
# Tables
"table", "thead", "tbody", "tr", "th", "td",
# Inline
"em", "strong", "kbd", "sup", "sub", "abbr",
# Links and images
"a", "img",
]
ALLOWED_ATTRS = {
"*": ["class", "id", "title"],
"a": ["href", "name", "target", "rel"],
"img": ["src", "alt", "title", "width", "height", "loading"],
}
ALLOWED_PROTOCOLS = ["http", "https", "mailto", "tel", "data"] # data for images (pasted)
class WikiIndex:
"""
Simple index for resolving [[WikiLinks]] by title, alias, or slug.
"""
def __init__(self):
self._map: Dict[str, Dict] = {}
@staticmethod
def _norm(key: str) -> str:
return key.strip().lower()
def add(self, *, id: str, title: str, slug: str, aliases: Iterable[str] = ()):
keys = [title, slug, *list(aliases)]
for k in keys:
n = self._norm(k)
if n and n not in self._map:
self._map[n] = {"id": id, "title": title, "slug": slug}
def resolve(self, key: str) -> Optional[Dict]:
return self._map.get(self._norm(key))
def build_wiki_index(notes: Iterable) -> WikiIndex:
idx = WikiIndex()
for n in notes:
idx.add(id=n.id, title=n.title, slug=n.slug, aliases=getattr(n, "aliases", []))
return idx
def _rewrite_attachment_paths(md_text: str) -> str:
"""
Turn relative markdown links/images to attachments (attachments/... or ./attachments/...)
into absolute app URLs (/attachments/...) so they work from any route.
"""
# Replace ![alt](attachments/...) and [text](attachments/...) patterns
def repl(m: re.Match) -> str:
prefix = m.group(1) # ]( or ](./ or ](../ etc)
path = m.group(2)
# Normalize to /attachments/<path>
clean = re.sub(r"^(\./|/)?attachments/", "", path)
return f"{prefix}/attachments/{clean}"
pattern = re.compile(r"(\]\()(\.?/?attachments/[^\)]+)")
text = pattern.sub(repl, md_text)
# Images
img_pattern = re.compile(r"(!\[[^\]]*\]\()(\.?/?attachments/[^\)]+)")
text = img_pattern.sub(repl, text)
return text
def _wikilink_to_md_link(wikilink: str, idx: WikiIndex) -> Tuple[str, Optional[str]]:
"""
Convert a single [[...]] to a markdown [text](url) if resolvable.
Returns (replacement_text, resolved_id or None).
Supports [[Note]], [[Note|Alias]], [[Note#Heading]], [[Note#Heading|Alias]].
"""
inner = wikilink.strip()[2:-2] # remove [[ ]]
# Split alias part
if "|" in inner:
target_part, alias_text = inner.split("|", 1)
else:
target_part, alias_text = inner, None
# Handle heading fragment
if "#" in target_part:
target_title, header = target_part.split("#", 1)
else:
target_title, header = target_part, None
target_title = target_title.strip()
alias_text = alias_text.strip() if alias_text else None
hit = idx.resolve(target_title)
link_text = alias_text or (hit["title"] if hit else target_title)
if hit:
href = url_for("notes.notes_view", note_id=hit["id"])
if header:
href = f"{href}#{slugify(header)}"
return f"[{link_text}]({href})", hit["id"]
else:
# Unresolved — return a stylized placeholder anchor that won't navigate
safe_label = escape(target_title)
if header:
safe_label += f"#{escape(header)}"
disp = escape(alias_text) if alias_text else safe_label
html = f'<span class="text-warning/90 underline decoration-dotted" title="Unresolved wikilink">[[{disp}]]</span>'
return html, None
def _rewrite_wikilinks(md_text: str, idx: WikiIndex) -> Tuple[str, List[str], List[str]]:
"""
Replace all [[...]] links. Returns (new_text, resolved_ids, unresolved_texts)
"""
resolved: List[str] = []
unresolved: List[str] = []
def repl(m: re.Match) -> str:
w = m.group(0)
replacement, note_id = _wikilink_to_md_link(w, idx)
if note_id:
resolved.append(note_id)
else:
unresolved.append(w)
return replacement
# Match anything between [[ ]] non-greedy
pattern = re.compile(r"\[\[(.+?)\]\]")
new_text = pattern.sub(repl, md_text)
return new_text, resolved, unresolved
def sanitize_html(html: str) -> str:
cleaned = bleach.clean(
html,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRS,
protocols=ALLOWED_PROTOCOLS,
strip=True,
)
# Make external links open safely
cleaned = bleach.linkify(
cleaned,
callbacks=[],
skip_tags=["pre", "code"],
parse_email=True,
)
return cleaned
def render_markdown(body_md: str, all_notes: Iterable) -> dict:
"""
Render Markdown with:
- Obsidian-style [[WikiLinks]] (resolved by title/alias/slug).
- Attachment path rewriting to /attachments/...
- Markdown-it-py with useful plugins.
- Bleach sanitization.
Returns:
{
"html": "<sanitized html>",
"outbound_note_ids": [...],
"unresolved_wikilinks": ["[[...]]", ...]
}
"""
idx = build_wiki_index(all_notes)
# Preprocess: wikilinks -> markdown links (or styled unresolved spans), attachments -> absolute
text_with_attachments = _rewrite_attachment_paths(body_md or "")
text_with_links, resolved_ids, unresolved = _rewrite_wikilinks(text_with_attachments, idx)
# Render to HTML
raw_html = _md.render(text_with_links)
# Sanitize
safe_html = sanitize_html(raw_html)
return {
"html": safe_html,
"outbound_note_ids": resolved_ids,
"unresolved_wikilinks": unresolved,
}

View File

@@ -2,7 +2,8 @@ from __future__ import annotations
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import Iterable from pathlib import Path
from typing import Iterator
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -15,31 +16,48 @@ class VaultPaths:
class Vault: class Vault:
def __init__(self, root_path: str): def __init__(self, root_path: str):
if not root_path: root = Path(root_path).expanduser().resolve()
raise ValueError("Vault root path must be provided") object.__setattr__(
self.root_path = os.path.abspath(root_path) self,
self.paths = VaultPaths( "paths",
root=self.root_path, VaultPaths(
notes=os.path.join(self.root_path, "notes"), root=str(root),
attachments=os.path.join(self.root_path, "attachments"), notes=str(root / "notes"),
kb=os.path.join(self.root_path, ".kb"), attachments=str(root / "attachments"),
kb=str(root / ".kb"),
),
) )
def ensure_structure(self) -> None: def ensure_structure(self) -> None:
os.makedirs(self.paths.root, exist_ok=True) Path(self.paths.notes).mkdir(parents=True, exist_ok=True)
os.makedirs(self.paths.notes, exist_ok=True) Path(self.paths.attachments).mkdir(parents=True, exist_ok=True)
os.makedirs(self.paths.attachments, exist_ok=True) Path(self.paths.kb).mkdir(parents=True, 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: def abspath(self, rel_path: str) -> str:
return os.path.join(self.paths.root, rel_path) # If rel_path is absolute, Path(root) / rel_path will return rel_path as-is.
return str((Path(self.paths.root) / rel_path).resolve())
def iter_markdown_files(self) -> Iterable[str]: def relpath(self, abs_path: str) -> str:
"""Yield absolute file paths for all .md files under notes/ recursively.""" return str(Path(abs_path).resolve().relative_to(Path(self.paths.root).resolve()))
for dirpath, _, filenames in os.walk(self.paths.notes):
for fn in filenames: def iter_markdown_files(self) -> Iterator[str]:
if fn.lower().endswith(".md"): """
yield os.path.join(dirpath, fn) Yield absolute paths to .md files under <vault>/notes recursively.
- Allows the vault root to be hidden or not.
- Skips hidden subdirectories within notes/ (names starting with '.').
- Skips hidden files (names starting with '.').
"""
notes_dir = Path(self.paths.notes)
if not notes_dir.exists():
return iter(())
# Walk manually to filter hidden dirs/files
for dirpath, dirnames, filenames in os.walk(notes_dir):
# Remove hidden subdirectories in-place (prevents os.walk from entering them)
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
for fname in filenames:
if fname.startswith("."):
continue
if not fname.endswith(".md"):
continue
yield str(Path(dirpath, fname).resolve())

View File

@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{% block title %}PKM{% endblock %}</title> <title>{% block title %}PKM{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}" /> <link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}" />
<!-- Optional: highlight.js for code blocks -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" integrity="sha512-XJ7Xo4x2XQHkYHqJ8Zg0t8zqOud0a5Cwq3kP2CF0K6Qq6z8w1K9u8Cuo7J8iW0h6Sbp2eC4O8X1cK9w3v8g4WQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script defer src="{{ url_for('static', filename='js/app.js') }}"></script> <script defer src="{{ url_for('static', filename='js/app.js') }}"></script>
</head> </head>
<body class="min-h-screen bg-base-200"> <body class="min-h-screen bg-base-200">
@@ -31,5 +33,11 @@
<p>PKM — Flask + Tailwind + DaisyUI</p> <p>PKM — Flask + Tailwind + DaisyUI</p>
</aside> </aside>
</footer> </footer>
<!-- highlight.js script (after content) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" integrity="sha512-yc6f6QkBqKPNgRr2DscxL8s4nq8kqJxU3K6Q0dZQ9+KNCm+q9bQz7vZ+2b+fS2bQ4pQ9v5g2nV1cQmcvj2Wm+Q==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
try { window.hljs && window.hljs.highlightAll(); } catch (e) {}
</script>
</body> </body>
</html> </html>

View File

@@ -22,20 +22,15 @@
{% endif %} {% endif %}
</div> </div>
<div class="mt-4"> {% if unresolved_wikilinks and unresolved_wikilinks|length > 0 %}
<h2 class="text-lg font-medium mb-2">Body (raw Markdown)</h2> <div class="mt-4 alert alert-warning">
<pre class="p-4 bg-base-200 rounded overflow-x-auto whitespace-pre-wrap">{{ note.body }}</pre> <span>{{ unresolved_wikilinks|length }} unresolved link{{ 's' if unresolved_wikilinks|length != 1 else '' }}.</span>
</div> </div>
{% endif %}
<div class="mt-6"> <article class="prose max-w-none mt-6">
<h3 class="font-medium mb-2">Quick edit</h3> {{ rendered_html|safe }}
<form method="post" action="{{ url_for('notes.notes_update_body', note_id=note.id) }}"> </article>
<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 class="mt-6 opacity-70 text-sm">
<div>File: <code class="kbd kbd-sm">{{ note.rel_path }}</code></div> <div>File: <code class="kbd kbd-sm">{{ note.rel_path }}</code></div>

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "pkm-frontend", "name": "pkm-frontend",
"version": "0.1.2", "version": "0.1.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pkm-frontend", "name": "pkm-frontend",
"version": "0.1.2", "version": "0.1.5",
"devDependencies": { "devDependencies": {
"@tailwindcss/cli": "^4.1.12", "@tailwindcss/cli": "^4.1.12",
"daisyui": "^5.0.50", "daisyui": "^5.0.50",

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "pkm" name = "pkm"
version = "0.1.1" version = "0.2.0"
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"
@@ -14,6 +14,9 @@ dependencies = [
"click>=8.1", "click>=8.1",
"python-frontmatter>=1.0.1", "python-frontmatter>=1.0.1",
"PyYAML>=6.0.2", "PyYAML>=6.0.2",
"markdown-it-py>=3.0.0",
"mdit-py-plugins>=0.4.0",
"bleach>=6.1.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -3,3 +3,6 @@ python-dotenv>=1.0
click>=8.1 click>=8.1
python-frontmatter>=1.0.1 python-frontmatter>=1.0.1
PyYAML>=6.0.2 PyYAML>=6.0.2
markdown-it-py>=3.0.0
mdit-py-plugins>=0.4.0
bleach>=6.1.0