Files
Orchestrator/privat/NoteGraph/ingestion/web/browse.py
T
ankn a5f8fb49ab Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
      Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
      Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)

Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
2026-06-30 20:39:52 +02:00

1469 lines
52 KiB
Python

"""Notes browser — timeline view, search, and quick capture.
Mounted at /browse on the NoteGraph ingestion service.
Auth: same token-based auth as the upload endpoint (SB_USER env var).
"""
import logging
import os
import re
import secrets
from datetime import date, datetime
from pathlib import Path
from typing import Optional
import yaml
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel
from slugify import slugify
from ingestion.config import IngestionConfig
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/browse")
def _get_config() -> IngestionConfig:
return IngestionConfig()
def _get_notes_dir() -> Path:
"""Resolve notes directory — env override or default relative to repo root."""
env_notes = os.environ.get("NOTES_DIR")
if env_notes:
return Path(env_notes)
# Default: ../notes/ relative to the ingestion package
return Path(__file__).resolve().parent.parent.parent / "notes"
def _verify_token(request: Request, token: str = None):
"""Verify upload token from query param or X-Upload-Token header."""
config = _get_config()
sb_user = config.sb_user.strip()
parts = sb_user.split(":", 1)
expected_token = parts[1].strip() if len(parts) == 2 else sb_user
# Check query param
if token and secrets.compare_digest(token.encode(), expected_token.encode()):
return True
# Check header
header_token = request.headers.get("X-Upload-Token", "")
if header_token and secrets.compare_digest(header_token.encode(), expected_token.encode()):
return True
raise HTTPException(status_code=401, detail="Invalid or missing token. Use ?token=YOUR_PASSWORD")
def _parse_frontmatter(content: str) -> dict:
"""Parse YAML frontmatter from markdown content."""
if not content.startswith("---"):
return {}
parts = content.split("---", 2)
if len(parts) < 3:
return {}
try:
return yaml.safe_load(parts[1]) or {}
except yaml.YAMLError:
return {}
def _get_body(content: str) -> str:
"""Get markdown body (after frontmatter)."""
if not content.startswith("---"):
return content
parts = content.split("---", 2)
if len(parts) < 3:
return content
return parts[2].strip()
def _first_line_preview(body: str, max_len: int = 120) -> str:
"""Get first non-empty, non-heading line as preview."""
for line in body.split("\n"):
line = line.strip()
if line and not line.startswith("#"):
return line[:max_len] + ("..." if len(line) > max_len else "")
return ""
def _scan_notes(notes_dir: Path) -> list[dict]:
"""Scan all markdown files and return metadata list."""
notes = []
if not notes_dir.exists():
return notes
for md_file in sorted(notes_dir.rglob("*.md")):
# Skip index files, templates, and trash
if md_file.name == "index.md" or "templates" in md_file.parts or "_trash" in md_file.parts:
continue
try:
content = md_file.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
fm = _parse_frontmatter(content)
body = _get_body(content)
# Extract date — from frontmatter or filename or file mtime
note_date = None
if "date" in fm:
d = fm["date"]
if isinstance(d, date):
note_date = d.isoformat()
elif isinstance(d, str):
note_date = d
if not note_date:
# Try to extract from filename like 2026-05-15-...
match = re.match(r"(\d{4}-\d{2}-\d{2})", md_file.name)
if match:
note_date = match.group(1)
if not note_date:
note_date = datetime.fromtimestamp(md_file.stat().st_mtime).strftime("%Y-%m-%d")
# Relative path from notes_dir
rel_path = str(md_file.relative_to(notes_dir)).replace("\\", "/")
notes.append({
"title": fm.get("title", md_file.stem.replace("-", " ").title()),
"date": note_date,
"tags": fm.get("tags", []),
"path": rel_path,
"preview": _first_line_preview(body),
})
# Sort by date descending
notes.sort(key=lambda n: n["date"], reverse=True)
return notes
# --- API Endpoints ---
@router.get("", response_class=HTMLResponse)
@router.get("/", response_class=HTMLResponse)
async def browse_page(request: Request, token: str = Query(default=None)):
"""Serve the notes browser HTML page."""
_verify_token(request, token)
return BROWSE_HTML
@router.get("/api/notes")
async def list_notes(request: Request, token: str = Query(default=None)):
"""Return JSON list of all notes."""
_verify_token(request, token)
notes_dir = _get_notes_dir()
notes = _scan_notes(notes_dir)
return JSONResponse(content=notes)
@router.get("/api/notes/{path:path}")
async def get_note(path: str, request: Request, token: str = Query(default=None)):
"""Return full content of a single note."""
_verify_token(request, token)
notes_dir = _get_notes_dir()
note_file = notes_dir / path
# Security: ensure path doesn't escape notes dir
try:
note_file.resolve().relative_to(notes_dir.resolve())
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
if not note_file.exists() or not note_file.is_file():
raise HTTPException(status_code=404, detail="Note not found")
content = note_file.read_text(encoding="utf-8")
fm = _parse_frontmatter(content)
body = _get_body(content)
return JSONResponse(content={
"title": fm.get("title", note_file.stem.replace("-", " ").title()),
"date": str(fm.get("date", "")),
"tags": fm.get("tags", []),
"path": path,
"content": body,
})
class NoteCreate(BaseModel):
title: str
content: str
tags: list[str] = []
class NoteUpdate(BaseModel):
title: str
content: str
tags: list[str] = []
class NoteSplit(BaseModel):
splitAt: int
newTitle: str = ""
@router.post("/api/notes")
async def create_note(note: NoteCreate, request: Request, token: str = Query(default=None)):
"""Create a new note with frontmatter."""
_verify_token(request, token)
notes_dir = _get_notes_dir()
inbox_dir = notes_dir / "inbox"
inbox_dir.mkdir(parents=True, exist_ok=True)
today = date.today().isoformat()
slug = slugify(note.title, max_length=60)
filename = f"{today}-{slug}.md"
filepath = inbox_dir / filename
# Build frontmatter
frontmatter = {
"title": note.title,
"date": today,
}
if note.tags:
frontmatter["tags"] = note.tags
fm_str = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True).strip()
md_content = f"---\n{fm_str}\n---\n\n# {note.title}\n\n{note.content}\n"
filepath.write_text(md_content, encoding="utf-8")
logger.info("Created note: %s", filepath)
rel_path = str(filepath.relative_to(notes_dir)).replace("\\", "/")
return JSONResponse(content={"message": "Note created", "path": rel_path}, status_code=201)
@router.delete("/api/notes/{path:path}")
async def delete_note(path: str, request: Request, token: str = Query(default=None)):
"""Delete a note by moving it to _trash/ subfolder."""
_verify_token(request, token)
notes_dir = _get_notes_dir()
note_file = notes_dir / path
# Security: ensure path doesn't escape notes dir
try:
note_file.resolve().relative_to(notes_dir.resolve())
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
if not note_file.exists() or not note_file.is_file():
raise HTTPException(status_code=404, detail="Note not found")
# Move to _trash/ preserving relative structure
trash_dir = notes_dir / "_trash"
trash_path = trash_dir / path
trash_path.parent.mkdir(parents=True, exist_ok=True)
# If a file with the same name already exists in trash, add timestamp
if trash_path.exists():
stem = trash_path.stem
suffix = trash_path.suffix
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
trash_path = trash_path.with_name(f"{stem}_{timestamp}{suffix}")
note_file.rename(trash_path)
logger.info("Moved note to trash: %s -> %s", note_file, trash_path)
return JSONResponse(content={"message": "Note moved to trash", "trashPath": str(trash_path.relative_to(notes_dir)).replace("\\", "/")})
@router.put("/api/notes/{path:path}")
async def update_note(path: str, note: NoteUpdate, request: Request, token: str = Query(default=None)):
"""Update a note's content, title, and tags."""
_verify_token(request, token)
notes_dir = _get_notes_dir()
note_file = notes_dir / path
# Security: ensure path doesn't escape notes dir
try:
note_file.resolve().relative_to(notes_dir.resolve())
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
if not note_file.exists() or not note_file.is_file():
raise HTTPException(status_code=404, detail="Note not found")
# Read existing to preserve date
existing_content = note_file.read_text(encoding="utf-8")
existing_fm = _parse_frontmatter(existing_content)
# Build updated frontmatter
frontmatter = {
"title": note.title,
"date": existing_fm.get("date", date.today().isoformat()),
}
if note.tags:
frontmatter["tags"] = note.tags
fm_str = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True).strip()
md_content = f"---\n{fm_str}\n---\n\n{note.content}\n"
note_file.write_text(md_content, encoding="utf-8")
logger.info("Updated note: %s", note_file)
return JSONResponse(content={"message": "Note updated", "path": path})
@router.post("/api/notes/{path:path}/split")
async def split_note(path: str, split: NoteSplit, request: Request, token: str = Query(default=None)):
"""Split a note at a given line number. Content above stays, content below becomes a new note."""
_verify_token(request, token)
notes_dir = _get_notes_dir()
note_file = notes_dir / path
# Security: ensure path doesn't escape notes dir
try:
note_file.resolve().relative_to(notes_dir.resolve())
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
if not note_file.exists() or not note_file.is_file():
raise HTTPException(status_code=404, detail="Note not found")
content = note_file.read_text(encoding="utf-8")
fm = _parse_frontmatter(content)
body = _get_body(content)
lines = body.split("\n")
if split.splitAt < 1 or split.splitAt >= len(lines):
raise HTTPException(status_code=400, detail=f"splitAt must be between 1 and {len(lines) - 1}")
# Split the body
upper_body = "\n".join(lines[:split.splitAt])
lower_body = "\n".join(lines[split.splitAt:])
# Update original note with upper part
original_title = fm.get("title", note_file.stem.replace("-", " ").title())
original_tags = fm.get("tags", [])
original_date = fm.get("date", date.today().isoformat())
fm_original = {
"title": original_title,
"date": original_date,
}
if original_tags:
fm_original["tags"] = original_tags
fm_str = yaml.dump(fm_original, default_flow_style=False, allow_unicode=True).strip()
note_file.write_text(f"---\n{fm_str}\n---\n\n{upper_body}\n", encoding="utf-8")
# Create new note from lower part in inbox/
new_title = split.newTitle.strip() if split.newTitle else f"Split from {original_title}"
today = date.today().isoformat()
slug = slugify(new_title, max_length=60)
filename = f"{today}-{slug}.md"
inbox_dir = notes_dir / "inbox"
inbox_dir.mkdir(parents=True, exist_ok=True)
new_filepath = inbox_dir / filename
fm_new = {
"title": new_title,
"date": today,
}
if original_tags:
fm_new["tags"] = original_tags
fm_new_str = yaml.dump(fm_new, default_flow_style=False, allow_unicode=True).strip()
new_filepath.write_text(f"---\n{fm_new_str}\n---\n\n{lower_body}\n", encoding="utf-8")
logger.info("Split note %s at line %d -> new note %s", note_file, split.splitAt, new_filepath)
new_rel_path = str(new_filepath.relative_to(notes_dir)).replace("\\", "/")
return JSONResponse(content={
"message": "Note split successfully",
"originalPath": path,
"newPath": new_rel_path,
"newTitle": new_title,
}, status_code=201)
@router.get("/api/backlinks/{path:path}")
async def get_backlinks(path: str, request: Request, token: str = Query(default=None)):
"""Return all notes that contain a wiki-link referencing the given path.
For example, requesting backlinks for 'people/john-doe.md' will search
all notes for the pattern [[people/john-doe]].
"""
_verify_token(request, token)
notes_dir = _get_notes_dir()
# Strip .md extension to get the wiki-link target
link_target = path
if link_target.endswith(".md"):
link_target = link_target[:-3]
# The pattern to search for: [[people/john-doe]] or [[projects/my-project]]
search_pattern = f"[[{link_target}]]"
results = []
if not notes_dir.exists():
return JSONResponse(content=[])
for md_file in notes_dir.rglob("*.md"):
if md_file.name == "index.md" or "templates" in md_file.parts or "_trash" in md_file.parts:
continue
# Don't include the target note itself
rel_path = str(md_file.relative_to(notes_dir)).replace("\\", "/")
if rel_path == path:
continue
try:
content = md_file.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
if search_pattern not in content:
continue
fm = _parse_frontmatter(content)
body = _get_body(content)
# Find the line containing the link for context
context = ""
for line in body.split("\n"):
if search_pattern in line:
context = line.strip()[:150]
break
note_date = None
if "date" in fm:
d = fm["date"]
note_date = d.isoformat() if isinstance(d, date) else str(d)
if not note_date:
match = re.match(r"(\d{4}-\d{2}-\d{2})", md_file.name)
note_date = match.group(1) if match else datetime.fromtimestamp(md_file.stat().st_mtime).strftime("%Y-%m-%d")
results.append({
"title": fm.get("title", md_file.stem.replace("-", " ").title()),
"date": note_date,
"tags": fm.get("tags", []),
"path": rel_path,
"preview": context or _first_line_preview(body),
})
results.sort(key=lambda n: n["date"], reverse=True)
return JSONResponse(content=results)
@router.get("/api/search")
async def search_notes(request: Request, q: str = Query(...), token: str = Query(default=None)):
"""Full-text search across all notes."""
_verify_token(request, token)
notes_dir = _get_notes_dir()
if not q or len(q) < 2:
return JSONResponse(content=[])
query_lower = q.lower()
results = []
if not notes_dir.exists():
return JSONResponse(content=[])
for md_file in notes_dir.rglob("*.md"):
if md_file.name == "index.md" or "templates" in md_file.parts or "_trash" in md_file.parts:
continue
try:
content = md_file.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
if query_lower not in content.lower():
continue
fm = _parse_frontmatter(content)
body = _get_body(content)
# Find matching line for context
context = ""
for line in body.split("\n"):
if query_lower in line.lower():
context = line.strip()[:150]
break
note_date = None
if "date" in fm:
d = fm["date"]
note_date = d.isoformat() if isinstance(d, date) else str(d)
if not note_date:
match = re.match(r"(\d{4}-\d{2}-\d{2})", md_file.name)
note_date = match.group(1) if match else datetime.fromtimestamp(md_file.stat().st_mtime).strftime("%Y-%m-%d")
rel_path = str(md_file.relative_to(notes_dir)).replace("\\", "/")
results.append({
"title": fm.get("title", md_file.stem.replace("-", " ").title()),
"date": note_date,
"tags": fm.get("tags", []),
"path": rel_path,
"preview": context or _first_line_preview(body),
})
results.sort(key=lambda n: n["date"], reverse=True)
return JSONResponse(content=results)
# --- Inline HTML Template ---
BROWSE_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NoteGraph — Browse</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
padding: 1rem;
}
.container {
max-width: 720px;
margin: 0 auto;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
header h1 {
font-size: 1.4rem;
color: #4fc3f7;
}
.btn-capture {
background: #4fc3f7;
color: #1a1a2e;
border: none;
border-radius: 8px;
padding: 0.5rem 1rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.3s;
}
.btn-capture:hover { background: #81d4fa; }
/* Search */
.search-bar {
width: 100%;
padding: 0.7rem 1rem;
border-radius: 8px;
border: 1px solid #333;
background: #16213e;
color: #eee;
font-size: 1rem;
margin-bottom: 0.8rem;
outline: none;
transition: border-color 0.3s;
}
.search-bar:focus { border-color: #4fc3f7; }
/* Date filters */
.filters {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.filter-btn {
background: #16213e;
color: #aaa;
border: 1px solid #333;
border-radius: 6px;
padding: 0.35rem 0.7rem;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.filter-btn:hover, .filter-btn.active {
background: #4fc3f7;
color: #1a1a2e;
border-color: #4fc3f7;
}
/* Tag filters */
.tag-filters {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.tag-filter-btn {
background: #16213e;
color: #81d4fa;
border: 1px solid #2a3a5e;
border-radius: 6px;
padding: 0.35rem 0.7rem;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.tag-filter-btn:hover, .tag-filter-btn.active {
background: #81d4fa;
color: #1a1a2e;
border-color: #81d4fa;
}
/* Notes list */
.notes-list { list-style: none; }
.note-item {
background: #16213e;
border-radius: 8px;
padding: 0.9rem 1rem;
margin-bottom: 0.6rem;
cursor: pointer;
transition: background 0.2s;
border: 1px solid transparent;
}
.note-item:hover {
background: #1b2a4a;
border-color: #4fc3f7;
}
.note-title {
font-size: 1rem;
font-weight: 600;
color: #eee;
margin-bottom: 0.25rem;
}
.note-meta {
font-size: 0.78rem;
color: #888;
margin-bottom: 0.3rem;
}
.note-meta .tag {
background: #2a3a5e;
color: #81d4fa;
padding: 0.1rem 0.4rem;
border-radius: 4px;
margin-left: 0.3rem;
font-size: 0.72rem;
}
.note-preview {
font-size: 0.85rem;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.empty-state {
text-align: center;
color: #666;
padding: 3rem 1rem;
font-size: 1rem;
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
z-index: 100;
align-items: center;
justify-content: center;
padding: 1rem;
}
.modal-overlay.open { display: flex; }
.modal {
background: #16213e;
border-radius: 12px;
padding: 1.5rem;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.modal h2 {
color: #4fc3f7;
margin-bottom: 1rem;
font-size: 1.2rem;
}
.modal label {
display: block;
font-size: 0.85rem;
color: #aaa;
margin-bottom: 0.3rem;
margin-top: 0.8rem;
}
.modal input, .modal textarea {
width: 100%;
padding: 0.6rem 0.8rem;
border-radius: 6px;
border: 1px solid #333;
background: #1a1a2e;
color: #eee;
font-size: 0.95rem;
outline: none;
font-family: inherit;
}
.modal input:focus, .modal textarea:focus { border-color: #4fc3f7; }
.modal textarea { min-height: 120px; resize: vertical; }
.modal-actions {
display: flex;
gap: 0.6rem;
margin-top: 1.2rem;
justify-content: flex-end;
}
.modal-actions button {
padding: 0.5rem 1.2rem;
border-radius: 6px;
border: none;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.btn-save { background: #4fc3f7; color: #1a1a2e; }
.btn-save:hover { background: #81d4fa; }
.btn-cancel { background: #333; color: #aaa; }
.btn-cancel:hover { background: #444; }
/* Note detail view */
.note-detail {
display: none;
background: #16213e;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1rem;
}
.note-detail.open { display: block; }
.note-detail h2 { color: #4fc3f7; margin-bottom: 0.5rem; }
.note-detail .meta { color: #888; font-size: 0.8rem; margin-bottom: 1rem; }
.note-detail .body {
color: #ddd;
font-size: 0.9rem;
line-height: 1.6;
word-wrap: break-word;
}
.note-detail .body h1 { font-size: 1.4rem; color: #4fc3f7; margin: 1rem 0 0.5rem; }
.note-detail .body h2 { font-size: 1.2rem; color: #81d4fa; margin: 0.8rem 0 0.4rem; }
.note-detail .body h3 { font-size: 1.05rem; color: #b3e5fc; margin: 0.6rem 0 0.3rem; }
.note-detail .body p { margin: 0.5rem 0; line-height: 1.6; }
.note-detail .body ul, .note-detail .body ol { padding-left: 1.5rem; margin: 0.5rem 0; }
.note-detail .body li { margin: 0.3rem 0; }
.note-detail .body code { background: #1a1a2e; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.85rem; color: #81d4fa; }
.note-detail .body pre { background: #1a1a2e; padding: 1rem; border-radius: 8px; overflow-x: auto; margin: 0.8rem 0; }
.note-detail .body pre code { background: none; padding: 0; }
.note-detail .body blockquote { border-left: 3px solid #4fc3f7; padding-left: 1rem; color: #aaa; margin: 0.8rem 0; }
.note-detail .body table { width: 100%; border-collapse: collapse; margin: 0.8rem 0; }
.note-detail .body th, .note-detail .body td { border: 1px solid #333; padding: 0.5rem 0.8rem; text-align: left; }
.note-detail .body th { background: #1a1a2e; color: #4fc3f7; }
.note-detail .body a { color: #4fc3f7; text-decoration: underline; }
.note-detail .body hr { border: none; border-top: 1px solid #333; margin: 1rem 0; }
.note-detail .body strong { color: #fff; }
.note-detail .body img { max-width: 100%; border-radius: 8px; }
.note-detail .btn-back {
background: #333;
color: #aaa;
border: none;
border-radius: 6px;
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
cursor: pointer;
margin-bottom: 1rem;
}
.note-detail .btn-back:hover { background: #444; }
/* Detail action buttons */
.detail-actions {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.btn-edit {
background: #ff9800;
color: #1a1a2e;
border: none;
border-radius: 6px;
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.btn-edit:hover { background: #ffb74d; }
.btn-delete {
background: #e53935;
color: #fff;
border: none;
border-radius: 6px;
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.btn-delete:hover { background: #ef5350; }
.btn-split {
background: #7c4dff;
color: #fff;
border: none;
border-radius: 6px;
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.btn-split:hover { background: #b388ff; }
.btn-save-edit {
background: #4caf50;
color: #fff;
border: none;
border-radius: 6px;
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.btn-save-edit:hover { background: #66bb6a; }
.btn-cancel-edit {
background: #555;
color: #ccc;
border: none;
border-radius: 6px;
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.btn-cancel-edit:hover { background: #666; }
/* Edit mode */
.edit-area {
width: 100%;
min-height: 200px;
padding: 0.8rem;
border-radius: 8px;
border: 1px solid #333;
background: #1a1a2e;
color: #eee;
font-size: 0.9rem;
font-family: 'Fira Code', 'Consolas', monospace;
line-height: 1.6;
resize: vertical;
outline: none;
}
.edit-area:focus { border-color: #4fc3f7; }
.edit-title-input {
width: 100%;
padding: 0.5rem 0.8rem;
border-radius: 6px;
border: 1px solid #333;
background: #1a1a2e;
color: #4fc3f7;
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 0.5rem;
outline: none;
}
.edit-title-input:focus { border-color: #4fc3f7; }
.edit-tags-input {
width: 100%;
padding: 0.4rem 0.8rem;
border-radius: 6px;
border: 1px solid #333;
background: #1a1a2e;
color: #aaa;
font-size: 0.8rem;
margin-bottom: 0.8rem;
outline: none;
}
.edit-tags-input:focus { border-color: #4fc3f7; }
.cursor-info {
font-size: 0.75rem;
color: #666;
margin-top: 0.3rem;
}
/* Backlinks section */
.backlinks-section {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #333;
}
.backlinks-title {
color: #81d4fa;
font-size: 0.95rem;
margin-bottom: 0.6rem;
}
.backlinks-list {
list-style: none;
padding: 0;
}
.backlinks-list li {
background: #1a1a2e;
border-radius: 6px;
padding: 0.6rem 0.8rem;
margin-bottom: 0.4rem;
cursor: pointer;
border: 1px solid #2a3a5e;
transition: border-color 0.2s, background 0.2s;
}
.backlinks-list li:hover {
border-color: #4fc3f7;
background: #1b2a4a;
}
.backlinks-list li .bl-title {
font-size: 0.9rem;
font-weight: 600;
color: #eee;
}
.backlinks-list li .bl-meta {
font-size: 0.72rem;
color: #888;
margin-top: 0.15rem;
}
.backlinks-list li .bl-preview {
font-size: 0.8rem;
color: #999;
margin-top: 0.2rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Wiki-links in note body */
.wiki-link {
color: #4fc3f7;
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
.wiki-link:hover {
color: #81d4fa;
text-decoration-style: solid;
}
.wiki-link.dead-link {
color: #e57373;
text-decoration-color: #e57373;
}
.wiki-link.dead-link:hover {
color: #ef9a9a;
}
/* Toast */
.toast {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
background: #1b5e20;
color: #a5d6a7;
padding: 0.7rem 1.2rem;
border-radius: 8px;
font-size: 0.9rem;
z-index: 200;
display: none;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
}
.toast.show { display: block; }
.toast.error { background: #b71c1c; color: #ef9a9a; }
/* Loading */
.loading { text-align: center; color: #666; padding: 2rem; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>🧠 NoteGraph</h1>
<button class="btn-capture" onclick="openCapture()">+ New Note</button>
</header>
<input type="text" class="search-bar" id="searchInput" placeholder="Search notes..." autocomplete="off">
<div class="filters">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="today">Today</button>
<button class="filter-btn" data-filter="week">This Week</button>
<button class="filter-btn" data-filter="month">This Month</button>
</div>
<div class="tag-filters" id="tagFilters" style="display:none;">
<!-- Populated dynamically -->
</div>
<div class="note-detail" id="noteDetail">
<button class="btn-back" onclick="closeDetail()">← Back</button>
<div class="detail-actions" id="detailActions">
<button class="btn-edit" id="btnEdit" onclick="enterEditMode()">✏️ Edit</button>
<button class="btn-delete" id="btnDelete" onclick="deleteNote()">🗑️ Delete</button>
</div>
<div id="viewMode">
<h2 id="detailTitle"></h2>
<div class="meta" id="detailMeta"></div>
<div class="body" id="detailBody"></div>
</div>
<div class="backlinks-section" id="backlinksSection" style="display:none;">
<h3 class="backlinks-title">📎 Referenced by</h3>
<ul class="backlinks-list" id="backlinksList"></ul>
</div>
<div id="editMode" style="display:none;">
<input type="text" class="edit-title-input" id="editTitle" placeholder="Title...">
<input type="text" class="edit-tags-input" id="editTags" placeholder="Tags (comma-separated)...">
<textarea class="edit-area" id="editArea"></textarea>
<div class="cursor-info" id="cursorInfo">Line: 1</div>
<div class="detail-actions" style="margin-top:0.8rem;">
<button class="btn-save-edit" onclick="saveEdit()">💾 Save</button>
<button class="btn-split" onclick="splitNote()">✂️ Split here</button>
<button class="btn-cancel-edit" onclick="cancelEdit()">Cancel</button>
</div>
</div>
</div>
<div class="loading" id="loading">Loading notes...</div>
<ul class="notes-list" id="notesList"></ul>
<div class="empty-state" id="emptyState" style="display:none;">No notes found.</div>
</div>
<!-- Capture Modal -->
<div class="modal-overlay" id="captureModal">
<div class="modal">
<h2>✏️ Quick Capture</h2>
<label for="noteTitle">Title</label>
<input type="text" id="noteTitle" placeholder="Note title...">
<label for="noteTags">Tags (comma-separated)</label>
<input type="text" id="noteTags" placeholder="e.g. meeting, project/x">
<label for="noteContent">Content</label>
<textarea id="noteContent" placeholder="Write your note..."></textarea>
<div class="modal-actions">
<button class="btn-cancel" onclick="closeCapture()">Cancel</button>
<button class="btn-save" onclick="saveNote()">Save</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const token = new URLSearchParams(window.location.search).get('token') || '';
const baseUrl = '/browse/api';
let allNotes = [];
let activeFilter = 'all';
let activeTagFilter = null;
let searchTimeout = null;
let currentNotePath = '';
let currentNoteTags = [];
let currentNoteContent = '';
let isEditMode = false;
// --- Init ---
document.addEventListener('DOMContentLoaded', loadNotes);
// --- Search ---
const searchInput = document.getElementById('searchInput');
searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const q = searchInput.value.trim();
if (q.length >= 2) {
searchServer(q);
} else {
renderNotes(applyFilters(allNotes));
}
}, 300);
});
// --- Date Filters ---
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeFilter = btn.dataset.filter;
const q = searchInput.value.trim();
if (q.length >= 2) {
searchServer(q);
} else {
renderNotes(applyFilters(allNotes));
}
});
});
function filterByDate(notes) {
if (activeFilter === 'all') return notes;
const now = new Date();
const today = now.toISOString().slice(0, 10);
if (activeFilter === 'today') {
return notes.filter(n => n.date === today);
}
if (activeFilter === 'week') {
const weekAgo = new Date(now - 7 * 86400000).toISOString().slice(0, 10);
return notes.filter(n => n.date >= weekAgo);
}
if (activeFilter === 'month') {
const monthAgo = new Date(now - 30 * 86400000).toISOString().slice(0, 10);
return notes.filter(n => n.date >= monthAgo);
}
return notes;
}
function filterByTag(notes) {
if (!activeTagFilter) return notes;
return notes.filter(n => n.tags && n.tags.includes(activeTagFilter));
}
function applyFilters(notes) {
return filterByTag(filterByDate(notes));
}
function renderTagFilters() {
const container = document.getElementById('tagFilters');
const tagSet = new Set();
allNotes.forEach(n => {
if (n.tags) n.tags.forEach(t => tagSet.add(t));
});
if (tagSet.size === 0) {
container.style.display = 'none';
return;
}
const tags = Array.from(tagSet).sort();
container.style.display = 'flex';
container.innerHTML = `<button class="tag-filter-btn${!activeTagFilter ? ' active' : ''}" data-tag="">All Tags</button>` +
tags.map(t => `<button class="tag-filter-btn${activeTagFilter === t ? ' active' : ''}" data-tag="${escHtml(t)}">${escHtml(t)}</button>`).join('');
container.querySelectorAll('.tag-filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
activeTagFilter = btn.dataset.tag || null;
container.querySelectorAll('.tag-filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const q = searchInput.value.trim();
if (q.length >= 2) {
searchServer(q);
} else {
renderNotes(applyFilters(allNotes));
}
});
});
}
// --- API Calls ---
async function loadNotes() {
try {
const resp = await fetch(`${baseUrl}/notes?token=${encodeURIComponent(token)}`);
if (!resp.ok) throw new Error('Failed to load notes');
allNotes = await resp.json();
document.getElementById('loading').style.display = 'none';
renderTagFilters();
renderNotes(applyFilters(allNotes));
} catch (e) {
document.getElementById('loading').textContent = 'Error loading notes: ' + e.message;
}
}
async function searchServer(q) {
try {
const resp = await fetch(`${baseUrl}/search?q=${encodeURIComponent(q)}&token=${encodeURIComponent(token)}`);
if (!resp.ok) throw new Error('Search failed');
const results = await resp.json();
renderNotes(applyFilters(results));
} catch (e) {
showToast('Search error: ' + e.message, true);
}
}
async function openNote(path) {
try {
const resp = await fetch(`${baseUrl}/notes/${path}?token=${encodeURIComponent(token)}`);
if (!resp.ok) throw new Error('Failed to load note');
const note = await resp.json();
currentNotePath = note.path;
currentNoteTags = note.tags || [];
currentNoteContent = note.content;
document.getElementById('detailTitle').textContent = note.title;
document.getElementById('detailMeta').textContent = `${note.date}` + (note.tags.length ? ' · ' + note.tags.join(', ') : '');
// Render wiki-links first, then parse markdown
let rendered = note.content;
// Convert [[people/name]] and [[projects/name]] to clickable links
rendered = rendered.replace(/\\[\\[([^\\]]+)\\]\\]/g, (match, target) => {
const display = target.split('/').pop().replace(/-/g, ' ');
return `<a class="wiki-link" href="#" onclick="openNote('${target}.md'); return false;">${display}</a>`;
});
document.getElementById('detailBody').innerHTML = marked.parse(rendered);
document.getElementById('noteDetail').classList.add('open');
document.getElementById('notesList').style.display = 'none';
document.getElementById('emptyState').style.display = 'none';
// Reset to view mode
exitEditMode();
// Load backlinks
loadBacklinks(note.path);
} catch (e) {
showToast('Error: ' + e.message, true);
}
}
function renderWikiLinks(html) {
// Replace [[path/slug]] with clickable links
// The html is already escaped, so [[ and ]] are literal
return html.replace(/\\[\\[([^\\]]+)\\]\\]/g, function(match, target) {
const filePath = target + '.md';
return `<span class="wiki-link" data-target="${escAttr(filePath)}" onclick="openWikiLink('${escAttr(filePath)}')">${escHtml(target.split('/').pop().replace(/-/g, ' '))}</span>`;
});
}
function escAttr(s) {
return s.replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function openWikiLink(filePath) {
openNote(filePath);
}
async function loadBacklinks(path) {
const section = document.getElementById('backlinksSection');
const list = document.getElementById('backlinksList');
section.style.display = 'none';
list.innerHTML = '';
try {
const resp = await fetch(`${baseUrl}/backlinks/${path}?token=${encodeURIComponent(token)}`);
if (!resp.ok) return;
const backlinks = await resp.json();
if (!backlinks.length) return;
section.style.display = 'block';
list.innerHTML = backlinks.map(bl => `
<li onclick="openNote('${bl.path.replace(/'/g, "\\'")}')">
<div class="bl-title">${escHtml(bl.title)}</div>
<div class="bl-meta">${bl.date}${bl.tags.length ? ' · ' + bl.tags.join(', ') : ''}</div>
<div class="bl-preview">${escHtml(bl.preview)}</div>
</li>
`).join('');
} catch (e) {
// Silently fail — backlinks are non-critical
}
}
function closeDetail() {
document.getElementById('noteDetail').classList.remove('open');
document.getElementById('notesList').style.display = '';
document.getElementById('backlinksSection').style.display = 'none';
exitEditMode();
currentNotePath = '';
currentNoteTags = [];
currentNoteContent = '';
}
// --- Edit Mode ---
function enterEditMode() {
isEditMode = true;
document.getElementById('viewMode').style.display = 'none';
document.getElementById('editMode').style.display = 'block';
document.getElementById('btnEdit').style.display = 'none';
const title = document.getElementById('detailTitle').textContent;
document.getElementById('editTitle').value = title;
document.getElementById('editTags').value = currentNoteTags.join(', ');
document.getElementById('editArea').value = currentNoteContent;
// Track cursor position
const editArea = document.getElementById('editArea');
editArea.addEventListener('click', updateCursorInfo);
editArea.addEventListener('keyup', updateCursorInfo);
editArea.focus();
}
function exitEditMode() {
isEditMode = false;
document.getElementById('viewMode').style.display = '';
document.getElementById('editMode').style.display = 'none';
document.getElementById('btnEdit').style.display = '';
}
function cancelEdit() {
exitEditMode();
}
function updateCursorInfo() {
const editArea = document.getElementById('editArea');
const text = editArea.value.substring(0, editArea.selectionStart);
const lineNum = text.split('\\n').length;
document.getElementById('cursorInfo').textContent = `Line: ${lineNum}`;
}
async function saveEdit() {
const title = document.getElementById('editTitle').value.trim();
const content = document.getElementById('editArea').value;
const tagsRaw = document.getElementById('editTags').value.trim();
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : [];
if (!title) { showToast('Title is required', true); return; }
try {
const resp = await fetch(`${baseUrl}/notes/${currentNotePath}?token=${encodeURIComponent(token)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content, tags })
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.detail || 'Update failed');
}
showToast('Note updated!');
// Refresh the detail view
openNote(currentNotePath);
loadNotes();
} catch (e) {
showToast('Error: ' + e.message, true);
}
}
// --- Delete ---
async function deleteNote() {
if (!currentNotePath) return;
const confirmed = confirm('Move this note to trash? This can be undone by restoring from _trash/ folder.');
if (!confirmed) return;
try {
const resp = await fetch(`${baseUrl}/notes/${currentNotePath}?token=${encodeURIComponent(token)}`, {
method: 'DELETE'
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.detail || 'Delete failed');
}
showToast('Note moved to trash');
closeDetail();
loadNotes();
} catch (e) {
showToast('Error: ' + e.message, true);
}
}
// --- Split ---
async function splitNote() {
const editArea = document.getElementById('editArea');
const text = editArea.value.substring(0, editArea.selectionStart);
const lineNum = text.split('\\n').length;
const totalLines = editArea.value.split('\\n').length;
if (lineNum < 1 || lineNum >= totalLines) {
showToast('Place your cursor where you want to split (not at the very end)', true);
return;
}
const defaultTitle = 'Split from ' + document.getElementById('editTitle').value.trim();
const newTitle = prompt('Title for the new note (content below cursor):', defaultTitle);
if (newTitle === null) return; // cancelled
try {
const resp = await fetch(`${baseUrl}/notes/${currentNotePath}/split?token=${encodeURIComponent(token)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ splitAt: lineNum, newTitle: newTitle || '' })
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.detail || 'Split failed');
}
const result = await resp.json();
showToast(`Note split! New note: ${result.newTitle}`);
// Refresh
openNote(currentNotePath);
loadNotes();
} catch (e) {
showToast('Error: ' + e.message, true);
}
}
// --- Render ---
function renderNotes(notes) {
const list = document.getElementById('notesList');
const empty = document.getElementById('emptyState');
if (!notes.length) {
list.innerHTML = '';
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
list.innerHTML = notes.map(n => `
<li class="note-item" onclick="openNote('${n.path.replace(/'/g, "\\'")}')">
<div class="note-title">${escHtml(n.title)}</div>
<div class="note-meta">
${n.date}
${n.tags.map(t => `<span class="tag">${escHtml(t)}</span>`).join('')}
</div>
<div class="note-preview">${escHtml(n.preview)}</div>
</li>
`).join('');
}
function escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// --- Capture Modal ---
function openCapture() {
document.getElementById('captureModal').classList.add('open');
document.getElementById('noteTitle').focus();
}
function closeCapture() {
document.getElementById('captureModal').classList.remove('open');
document.getElementById('noteTitle').value = '';
document.getElementById('noteTags').value = '';
document.getElementById('noteContent').value = '';
}
async function saveNote() {
const title = document.getElementById('noteTitle').value.trim();
const content = document.getElementById('noteContent').value.trim();
const tagsRaw = document.getElementById('noteTags').value.trim();
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : [];
if (!title) { showToast('Title is required', true); return; }
try {
const resp = await fetch(`${baseUrl}/notes?token=${encodeURIComponent(token)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content, tags })
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.detail || 'Save failed');
}
showToast('Note saved!');
closeCapture();
loadNotes();
} catch (e) {
showToast('Error: ' + e.message, true);
}
}
// --- Toast ---
function showToast(msg, isError = false) {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast show' + (isError ? ' error' : '');
setTimeout(() => t.className = 'toast', 3000);
}
// Close modal on overlay click
document.getElementById('captureModal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeCapture();
});
// Keyboard shortcut: Escape closes modal/detail
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeCapture();
closeDetail();
}
});
</script>
</body>
</html>"""