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.
1469 lines
52 KiB
Python
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, '&').replace(/'/g, ''').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
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>"""
|