diff --git a/.gitignore b/.gitignore index 32755eb..89b7f14 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ app/static/css/app.css.map Thumbs.db .vault/ +vault/ diff --git a/README.md b/README.md index bd28451..342a81e 100644 --- a/README.md +++ b/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 + - Python 3.9+ -- Node.js 18+ (for Tailwind CLI) -- SQLite (system-provided; FTS5 required later, not needed in M0) +- Node.js 18+ +- SQLite (FTS5 needed later in M4; not required yet) -## Setup +--- + +## Setup (Python) + +Create and activate a virtual environment: -### 1) Python environment ```bash -python -m venv .venv +python3 -m venv .venv # macOS/Linux source .venv/bin/activate # Windows PowerShell -# .venv\\Scripts\\Activate.ps1 - -pip install -r requirements.txt +# .venv\Scripts\Activate.ps1 ``` -Optionally install as a package: +Install Python dependencies: + ```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 npm install -# One-time build +``` + +Build CSS once: + +```bash 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 ``` -### 3) Configure vault path -Create a `.env` from the example and set your vault directory: +This repository uses: +- `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 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: + +``` +/ +├─ notes/ +├─ attachments/ +└─ .kb/ + ├─ config.yml + └─ (index.sqlite will be added in M4) +``` + +--- ## Run the app -Option A — via Flask CLI (loads `create_app` automatically): +Using the provided CLI (explicit python3): + ```bash -# Ensure .env is present or set KB_VAULT_PATH in env -flask --app app run --debug +python3 cli.py run --vault /absolute/path/to/vault +# 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 -# Using .env -python cli.py run -# Or pass vault explicitly -python cli.py run --vault /path/to/vault +python3 cli.py run ``` -Then open: +Open the app at: ``` http://127.0.0.1:5000/ ``` -You should see: -- A welcome card with your configured vault path (or a warning if not set). -- Tailwind + DaisyUI styles active. -- A theme selector (light/dark/cupcake) in the navbar. +You can: +- Create a note at “New Note” +- View “Notes” to list them +- Use `[[WikiLinks]]` between notes +- Reference attachments via `attachments/` (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+) -- Implement note model, vault FS access, and basic CRUD. -- Add markdown rendering, sanitization, and set up the SQLite schema for indexing. \ No newline at end of file +## Tests + +```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 `/.kb/index.sqlite`. +- Later: Renames + link rewrites, Git history, command palette, etc. + +--- \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 8e8cb54..7370e96 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -15,19 +15,20 @@ def create_app(config_override=None) -> Flask: app.config["SECRET_KEY"] = cfg.SECRET_KEY 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 from .routes.home import bp as home_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(notes_bp) + app.register_blueprint(attachments_bp) @app.get("/healthz") def healthz(): diff --git a/app/routes/attachments.py b/app/routes/attachments.py new file mode 100644 index 0000000..0a6de8e --- /dev/null +++ b/app/routes/attachments.py @@ -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("/") +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))) \ No newline at end of file diff --git a/app/routes/notes.py b/app/routes/notes.py index 098e27f..aadb496 100644 --- a/app/routes/notes.py +++ b/app/routes/notes.py @@ -1,8 +1,12 @@ 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.renderer import render_markdown +from app.services.vault import Vault +import frontmatter +import os bp = Blueprint("notes", __name__, url_prefix="/notes") @@ -37,8 +41,17 @@ 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) + + 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("//body") @@ -47,4 +60,60 @@ def notes_update_body(note_id: str): note = update_note_body(note_id, new_body) if not note: abort(404) - return redirect(url_for("notes.notes_view", note_id=note.id)) \ No newline at end of file + 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""] + + 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, + }) \ No newline at end of file diff --git a/app/services/notes_fs.py b/app/services/notes_fs.py index f6a5765..743c139 100644 --- a/app/services/notes_fs.py +++ b/app/services/notes_fs.py @@ -1,7 +1,7 @@ from __future__ import annotations -import json import os +import re import uuid from dataclasses import dataclass, field 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 -REQUIRED_FIELDS = ("id", "title", "created", "updated") OPTIONAL_LIST_FIELDS = ("aliases", "tags") @@ -42,7 +41,6 @@ def _normalize_metadata(meta: dict) -> dict: 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)] @@ -112,15 +110,17 @@ def note_path_for_slug(vault: Vault, slug: str) -> Tuple[str, str]: 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) +def _load_frontmatter_text(abs_path: str): + # Always read as text for python-frontmatter + 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) 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( @@ -161,8 +161,6 @@ def create_note( 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( @@ -193,29 +191,46 @@ def save_note(vault: Vault, note: Note) -> None: def list_notes() -> list[Note]: vault = get_vault() notes: list[Note] = [] - for path in vault.iter_markdown_files(): + for p in vault.iter_markdown_files(): 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: - # Ignore malformed files for now (could log) continue - # Sort by updated desc notes.sort(key=lambda n: n.updated, reverse=True) 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]: 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: - 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) + abs_path = p if os.path.isabs(p) else vault.abspath(p) + post = _load_frontmatter_text(abs_path) + raw_id = (post.metadata or {}).get("id") + cand_key = _uuid_key(raw_id) + if cand_key and cand_key == target_key: + return load_note_from_file(vault, abs_path) except Exception: continue + return None @@ -224,7 +239,6 @@ def update_note_body(note_id: str, new_body: str) -> Optional[Note]: if not note: return None note.body = new_body - # Update timestamp note.updated = now_iso_utc() vault = get_vault() save_note(vault, note) diff --git a/app/services/renderer.py b/app/services/renderer.py new file mode 100644 index 0000000..e9457cc --- /dev/null +++ b/app/services/renderer.py @@ -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/ + 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'[[{disp}]]' + 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": "", + "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, + } \ No newline at end of file diff --git a/app/services/vault.py b/app/services/vault.py index 0573afc..b9b1195 100644 --- a/app/services/vault.py +++ b/app/services/vault.py @@ -2,7 +2,8 @@ from __future__ import annotations import os from dataclasses import dataclass -from typing import Iterable +from pathlib import Path +from typing import Iterator @dataclass(frozen=True) @@ -15,31 +16,48 @@ class VaultPaths: 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"), + root = Path(root_path).expanduser().resolve() + object.__setattr__( + self, + "paths", + VaultPaths( + root=str(root), + notes=str(root / "notes"), + attachments=str(root / "attachments"), + kb=str(root / ".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) + Path(self.paths.notes).mkdir(parents=True, exist_ok=True) + Path(self.paths.attachments).mkdir(parents=True, exist_ok=True) + Path(self.paths.kb).mkdir(parents=True, exist_ok=True) 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]: - """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) \ No newline at end of file + def relpath(self, abs_path: str) -> str: + return str(Path(abs_path).resolve().relative_to(Path(self.paths.root).resolve())) + + def iter_markdown_files(self) -> Iterator[str]: + """ + Yield absolute paths to .md files under /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()) \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 5490775..d9ca6f8 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -5,6 +5,8 @@ {% block title %}PKM{% endblock %} + + @@ -31,5 +33,11 @@

PKM — Flask + Tailwind + DaisyUI

+ + + + \ No newline at end of file diff --git a/app/templates/notes/view.html b/app/templates/notes/view.html index e2a8547..7221468 100644 --- a/app/templates/notes/view.html +++ b/app/templates/notes/view.html @@ -22,20 +22,15 @@ {% endif %} -
-

Body (raw Markdown)

-
{{ note.body }}
-
+ {% if unresolved_wikilinks and unresolved_wikilinks|length > 0 %} +
+ {{ unresolved_wikilinks|length }} unresolved link{{ 's' if unresolved_wikilinks|length != 1 else '' }}. +
+ {% endif %} -
-

Quick edit

-
- -
- -
-
-
+
+ {{ rendered_html|safe }} +
File: {{ note.rel_path }}
diff --git a/package-lock.json b/package-lock.json index 8552ac3..6ae4c15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pkm-frontend", - "version": "0.1.2", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pkm-frontend", - "version": "0.1.2", + "version": "0.1.5", "devDependencies": { "@tailwindcss/cli": "^4.1.12", "daisyui": "^5.0.50", diff --git a/pyproject.toml b/pyproject.toml index 89520b8..8297072 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pkm" -version = "0.1.1" +version = "0.2.0" description = "Personal Knowledge Manager (Flask + Tailwind + DaisyUI)" readme = "README.md" requires-python = ">=3.9" @@ -14,6 +14,9 @@ dependencies = [ "click>=8.1", "python-frontmatter>=1.0.1", "PyYAML>=6.0.2", + "markdown-it-py>=3.0.0", + "mdit-py-plugins>=0.4.0", + "bleach>=6.1.0", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index 26bc956..ae70a54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,7 @@ Flask>=3.0 python-dotenv>=1.0 click>=8.1 python-frontmatter>=1.0.1 -PyYAML>=6.0.2 \ No newline at end of file +PyYAML>=6.0.2 +markdown-it-py>=3.0.0 +mdit-py-plugins>=0.4.0 +bleach>=6.1.0 \ No newline at end of file