Milestone 2.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,3 +20,4 @@ app/static/css/app.css.map
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
.vault/
|
.vault/
|
||||||
|
vault/
|
||||||
|
230
README.md
230
README.md
@@ -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 (it’s 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.
|
||||||
|
|
||||||
|
---
|
@@ -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
32
app/routes/attachments.py
Normal 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)))
|
@@ -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,
|
||||||
|
})
|
@@ -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
212
app/services/renderer.py
Normal 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  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,
|
||||||
|
}
|
@@ -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())
|
@@ -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>
|
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||||
|
@@ -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]
|
||||||
|
@@ -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
|
Reference in New Issue
Block a user