789 lines
29 KiB
Python
Executable File
789 lines
29 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""mechanics for a project's .notes/ dir, see ~/.config/claude/rules/notes.md
|
|
|
|
usage:
|
|
notes.py [--root DIR] init create .notes/ with its git repo, if missing
|
|
notes.py [--root DIR] next d|w|q print the next free id of a bucket
|
|
notes.py [--root DIR] index regenerate index.html, notes.css, the markdown pages and
|
|
the portable copies of other/out/ in _out/
|
|
notes.py [--root DIR] commit SUBJECT regenerate the index, commit every change in .notes/
|
|
notes.py serve [--port 8765] [--roots DIR ...]
|
|
every project's notes on one port; roots default to
|
|
$NOTES_ROOTS, else ~/git
|
|
|
|
DIR is the project root, by default the git toplevel of the current dir, else the current dir
|
|
"""
|
|
|
|
import argparse
|
|
import html
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from urllib.parse import quote
|
|
|
|
BUCKETS = {"d": "docs", "w": "work", "q": "questions"}
|
|
ID_RE = re.compile(r"^(docs|work|questions)/([dwq])(\d{3})-")
|
|
README = (
|
|
"Local notes, never pushed. Layout and rules: ~/.config/claude/rules/notes.md\n"
|
|
)
|
|
|
|
|
|
def git(notes: Path, *args: str, check: bool = True) -> str:
|
|
return subprocess.run(
|
|
["git", "-C", str(notes), *args], check=check, capture_output=True, text=True
|
|
).stdout
|
|
|
|
|
|
def project_root(arg: str | None) -> Path:
|
|
if arg:
|
|
return Path(arg).resolve()
|
|
out = subprocess.run(
|
|
["git", "rev-parse", "--show-toplevel"],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
root = Path(out.stdout.strip()) if out.returncode == 0 else Path.cwd()
|
|
# inside .notes the toplevel is the notes repo itself
|
|
return root.parent if root.name == ".notes" else root
|
|
|
|
|
|
def init(notes: Path) -> None:
|
|
for d in ("docs", "work", "questions", "other/in", "other/out"):
|
|
(notes / d).mkdir(parents=True, exist_ok=True)
|
|
if not (notes / "README.md").exists():
|
|
(notes / "README.md").write_text(README)
|
|
if not (notes / ".gitignore").exists():
|
|
(notes / ".gitignore").write_text("/index.html\n")
|
|
if not (notes / ".git").exists():
|
|
git(notes, "init", "-q", "-b", "main")
|
|
# NOTE:(@janezicmatej) local-only repo, any identity works when none is configured
|
|
if not git(notes, "config", "user.email", check=False).strip():
|
|
git(notes, "config", "user.name", "notes")
|
|
git(notes, "config", "user.email", "notes@localhost")
|
|
commit(notes, "init notes")
|
|
else:
|
|
write_index(notes)
|
|
print(notes)
|
|
|
|
|
|
def next_id(notes: Path, letter: str) -> str:
|
|
bucket = BUCKETS[letter]
|
|
paths = [f"{bucket}/{p.name}" for p in (notes / bucket).glob("*")]
|
|
if (notes / ".git").exists():
|
|
log = git(notes, "log", "--all", "--name-only", "--pretty=format:", check=False)
|
|
paths += log.splitlines()
|
|
nums = [int(m[3]) for p in paths if (m := ID_RE.match(p)) and m[2] == letter]
|
|
return f"{letter}{max(nums, default=0) + 1:03d}"
|
|
|
|
|
|
def commit(notes: Path, subject: str) -> None:
|
|
write_index(notes)
|
|
git(notes, "add", "-A")
|
|
if git(notes, "status", "--porcelain").strip():
|
|
git(notes, "commit", "-q", "--no-gpg-sign", "-m", subject)
|
|
print(git(notes, "log", "-1", "--oneline").strip())
|
|
else:
|
|
print("nothing to commit")
|
|
|
|
|
|
# pages: index.html, notes.css, _pages/ and _out/ are generated, never committed
|
|
|
|
CSS = Path(__file__).with_name("notes.css")
|
|
GENERATED = ("/index.html", "/notes.css", "/_pages/", "/_out/")
|
|
PAGES = "_pages"
|
|
OUT = "_out"
|
|
CSS_LINK = re.compile(r'<link\b[^>]*href="[^"]*notes\.css"[^>]*>', re.IGNORECASE)
|
|
BACK_NAV = re.compile(r'\s*<nav class="back">.*?</nav>', re.DOTALL | re.IGNORECASE)
|
|
ID_TOKEN = re.compile(r"(?<![\w/.-])([dwq]\d{3})(?![\w/-])")
|
|
|
|
|
|
def ensure_gitignore(notes: Path) -> None:
|
|
path = notes / ".gitignore"
|
|
lines = path.read_text().splitlines() if path.exists() else []
|
|
missing = [g for g in GENERATED if g not in lines]
|
|
if missing:
|
|
path.write_text("\n".join([*lines, *missing]) + "\n")
|
|
|
|
|
|
def frontmatter(text: str) -> tuple[dict[str, str], str]:
|
|
m = re.match(r"^---\n(.*?)\n---\n?", text, re.DOTALL)
|
|
if not m:
|
|
return {}, text
|
|
meta = {}
|
|
for line in m[1].splitlines():
|
|
key, sep, value = line.partition(":")
|
|
if sep:
|
|
meta[key.strip()] = value.strip()
|
|
return meta, text[m.end() :]
|
|
|
|
|
|
def split_title(body: str, fallback: str) -> tuple[str, str]:
|
|
"""first heading as the title, without a leading id, and the body without it"""
|
|
m = re.search(r"^#\s+(.+)\n?", body, re.MULTILINE)
|
|
if not m:
|
|
return fallback, body
|
|
title = re.sub(r"^[dwq]\d{3}\b\s*", "", m[1].strip())
|
|
return title, body[: m.start()] + body[m.end() :]
|
|
|
|
|
|
def strip_tags(s: str) -> str:
|
|
return html.unescape(re.sub(r"<[^>]+>", "", s)).strip()
|
|
|
|
|
|
class Site:
|
|
"""what exists in .notes/ and where each item's page is, relative to .notes/"""
|
|
|
|
def __init__(self, notes: Path) -> None:
|
|
self.notes = notes
|
|
self.docs = self._docs()
|
|
self.work = sorted(p for p in (notes / "work").glob("w[0-9]*") if p.is_dir())
|
|
self.questions = sorted((notes / "questions").glob("q[0-9]*.md"))
|
|
self.other = {
|
|
sub: sorted(f for f in (notes / "other" / sub).rglob("*") if f.is_file())
|
|
for sub in ("in", "out")
|
|
if (notes / "other" / sub).exists()
|
|
}
|
|
self.urls: dict[str, str] = {}
|
|
for d in self.docs:
|
|
self.urls[d["id"]] = d["page"]
|
|
for w in self.work:
|
|
self.urls[w.name[:4]] = self.page_of(w / "brief.md")
|
|
for q in self.questions:
|
|
self.urls[q.name[:4]] = self.page_of(q)
|
|
|
|
def _docs(self) -> list[dict]:
|
|
out = []
|
|
for p in sorted((self.notes / "docs").glob("d[0-9]*")):
|
|
page = p / "index.html" if p.is_dir() else p
|
|
if not page.is_file() or page.suffix != ".html":
|
|
continue
|
|
text = page.read_text(errors="replace")
|
|
title = re.search(r"<title>(.*?)</title>", text, re.DOTALL | re.IGNORECASE)
|
|
status = re.search(
|
|
r"<p[^>]*class=\"[^\"]*\bstatus\b[^\"]*\"[^>]*>(.*?)</p>",
|
|
text,
|
|
re.DOTALL | re.IGNORECASE,
|
|
)
|
|
status_text = re.sub(
|
|
r"^status:\s*", "", strip_tags(status[1]) if status else ""
|
|
)
|
|
out.append(
|
|
{
|
|
"id": p.name[:4],
|
|
"title": strip_tags(title[1]) if title else p.stem,
|
|
"page": page.relative_to(self.notes).as_posix(),
|
|
"status": status_text,
|
|
}
|
|
)
|
|
return out
|
|
|
|
def page_of(self, src: Path) -> str:
|
|
"""where a markdown file's rendered page lives, relative to .notes/"""
|
|
rel = src.relative_to(self.notes).with_suffix(".html")
|
|
return f"{PAGES}/{rel.as_posix()}"
|
|
|
|
def href(self, target: str, from_page: str) -> str:
|
|
"""a link from one page to another, both relative to .notes/"""
|
|
base = Path(from_page).parent
|
|
return quote(Path(*([".."] * len(base.parts)), target).as_posix())
|
|
|
|
|
|
def status_tag(status: str) -> str:
|
|
e = html.escape
|
|
if status.startswith("current"):
|
|
return f'<span class="tag good" title="{e(status)}">current</span>'
|
|
if status.startswith("partly stale"):
|
|
return f'<span class="tag warn" title="{e(status)}">stale</span>'
|
|
if m := re.match(r"superseded by (d\d{3})", status):
|
|
return f'<span class="tag" title="{e(status)}">superseded by {m[1]}</span>'
|
|
return '<span class="tag bad">no status line</span>'
|
|
|
|
|
|
def page(site: Site, path: str, title: str, body: str) -> str:
|
|
css = site.href("notes.css", path)
|
|
return (
|
|
'<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n'
|
|
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
|
f"<title>{html.escape(title)}</title>\n"
|
|
f'<link rel="stylesheet" href="{css}">\n</head>\n'
|
|
f'<body>\n<main class="page">\n{body}\n</main>\n</body>\n</html>\n'
|
|
)
|
|
|
|
|
|
# markdown, the small subset notes are written in
|
|
|
|
|
|
def inline(text: str, site: Site, at: str) -> str:
|
|
out, last = [], 0
|
|
for m in re.finditer(r"`([^`]+)`", text):
|
|
out.append(inline_plain(text[last : m.start()], site, at))
|
|
out.append(f"<code>{html.escape(m[1])}</code>")
|
|
last = m.end()
|
|
out.append(inline_plain(text[last:], site, at))
|
|
return "".join(out)
|
|
|
|
|
|
def inline_plain(text: str, site: Site, at: str) -> str:
|
|
links: list[str] = []
|
|
|
|
def keep(s: str) -> str:
|
|
links.append(s)
|
|
return f"\x00{len(links) - 1}\x00"
|
|
|
|
text = re.sub(
|
|
r"\[([^\]]+)\]\(([^)\s]+)\)",
|
|
lambda m: keep(f'<a href="{html.escape(m[2])}">{html.escape(m[1])}</a>'),
|
|
text,
|
|
)
|
|
text = html.escape(text, quote=False)
|
|
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
|
|
|
|
def id_link(m: re.Match) -> str:
|
|
url = site.urls.get(m[1])
|
|
return f'<a href="{site.href(url, at)}">{m[1]}</a>' if url else m[1]
|
|
|
|
text = ID_TOKEN.sub(id_link, text)
|
|
return re.sub(r"\x00(\d+)\x00", lambda m: links[int(m[1])], text)
|
|
|
|
|
|
def markdown(text: str, site: Site, at: str) -> str:
|
|
lines = text.splitlines()
|
|
out: list[str] = []
|
|
para: list[str] = []
|
|
# open lists as (indent, tag)
|
|
lists: list[tuple[int, str]] = []
|
|
item_open = False
|
|
|
|
def flush_para() -> None:
|
|
if para:
|
|
out.append(f"<p>{inline(' '.join(para), site, at)}</p>")
|
|
para.clear()
|
|
|
|
def close_lists(to: int = -1) -> None:
|
|
nonlocal item_open
|
|
while lists and lists[-1][0] > to:
|
|
out.append(f"</li></{lists.pop()[1]}>")
|
|
item_open = bool(lists)
|
|
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
if line.lstrip().startswith("```"):
|
|
flush_para()
|
|
close_lists()
|
|
indent = len(line) - len(line.lstrip())
|
|
code = []
|
|
i += 1
|
|
while i < len(lines) and not lines[i].lstrip().startswith("```"):
|
|
code.append(lines[i][indent:])
|
|
i += 1
|
|
out.append(f"<pre><code>{html.escape(chr(10).join(code))}</code></pre>")
|
|
i += 1
|
|
continue
|
|
if not line.strip():
|
|
flush_para()
|
|
i += 1
|
|
continue
|
|
if m := re.match(r"^(#{1,4})\s+(.*)$", line):
|
|
flush_para()
|
|
close_lists()
|
|
level = min(len(m[1]) + 1, 4)
|
|
out.append(f"<h{level}>{inline(m[2], site, at)}</h{level}>")
|
|
i += 1
|
|
continue
|
|
if m := re.match(r"^(\s*)([-*]|\d+\.)\s+(.*)$", line):
|
|
flush_para()
|
|
indent, tag = len(m[1]), "ol" if m[2][0].isdigit() else "ul"
|
|
if not lists or indent > lists[-1][0]:
|
|
out.append(f"<{tag}><li>")
|
|
lists.append((indent, tag))
|
|
else:
|
|
close_lists(indent)
|
|
if lists and lists[-1][0] == indent:
|
|
out.append("</li><li>")
|
|
else:
|
|
out.append(f"<{tag}><li>")
|
|
lists.append((indent, tag))
|
|
item = [m[3]]
|
|
i += 1
|
|
# continuation lines belong to the item
|
|
while (
|
|
i < len(lines)
|
|
and lines[i].strip()
|
|
and not re.match(r"^\s*([-*]|\d+\.)\s+|^\s*```|^#{1,4}\s", lines[i])
|
|
):
|
|
item.append(lines[i].strip())
|
|
i += 1
|
|
out.append(inline(" ".join(item), site, at))
|
|
continue
|
|
if lists and line.startswith(" "):
|
|
out.append(" " + inline(line.strip(), site, at))
|
|
i += 1
|
|
continue
|
|
close_lists()
|
|
para.append(line.strip())
|
|
i += 1
|
|
flush_para()
|
|
close_lists()
|
|
return "\n".join(out)
|
|
|
|
|
|
def render_md(site: Site, src: Path, kind: str) -> None:
|
|
at = site.page_of(src)
|
|
meta, body = frontmatter(src.read_text(errors="replace"))
|
|
title, body = split_title(body, src.stem)
|
|
e = html.escape
|
|
item_id = next((p[:4] for p in src.parts if re.match(r"[dwq]\d{3}-", p)), "")
|
|
eyebrow = " · ".join(x for x in (item_id, kind) if x)
|
|
parts = [
|
|
f'<nav class="back"><a href="{site.href("index.html", at)}">← notes</a></nav>',
|
|
"<header>",
|
|
f'<p class="eyebrow">{e(eyebrow)}</p>' if eyebrow else "",
|
|
f"<h1>{e(title)}</h1>",
|
|
]
|
|
if meta:
|
|
dl = "".join(
|
|
f"<div><dt>{e(k)}</dt><dd>{inline(v, site, at)}</dd></div>"
|
|
for k, v in meta.items()
|
|
)
|
|
parts.append(f'<dl class="md-meta">{dl}</dl>')
|
|
parts += ["</header>", markdown(body, site, at)]
|
|
if kind == "work":
|
|
files = []
|
|
for f in sorted(src.parent.rglob("*")):
|
|
if not f.is_file() or f == src:
|
|
continue
|
|
target = site.page_of(f) if f.suffix == ".md" else f.relative_to(site.notes)
|
|
files.append(f'<a href="{site.href(str(target), at)}">{e(f.name)}</a>')
|
|
if files:
|
|
parts.append(f'<p class="files">files: {" · ".join(files)}</p>')
|
|
out = site.notes / at
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
out.write_text(page(site, at, title, "\n".join(p for p in parts if p)))
|
|
|
|
|
|
def clear(d: Path) -> None:
|
|
if d.exists():
|
|
for f in sorted(d.rglob("*"), reverse=True):
|
|
f.unlink() if f.is_file() else f.rmdir()
|
|
|
|
|
|
def write_portable(site: Site) -> None:
|
|
"""other/out/ as it leaves .notes/: css inlined, no back link to an index it won't have"""
|
|
out = site.notes / OUT
|
|
clear(out)
|
|
src = site.notes / "other" / "out"
|
|
style = f"<style>\n{CSS.read_text()}</style>"
|
|
for f in site.other.get("out", []):
|
|
target = out / f.relative_to(src)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
if f.suffix != ".html":
|
|
target.write_bytes(f.read_bytes())
|
|
continue
|
|
# the nav goes before the css comes in: its header comment shows a nav too
|
|
text = BACK_NAV.sub("", f.read_text(errors="replace"), count=1)
|
|
target.write_text(CSS_LINK.sub(lambda _: style, text, count=1))
|
|
|
|
|
|
def write_index(notes: Path) -> None:
|
|
ensure_gitignore(notes)
|
|
(notes / "notes.css").write_text(CSS.read_text())
|
|
site = Site(notes)
|
|
|
|
clear(notes / PAGES)
|
|
write_portable(site)
|
|
for q in site.questions:
|
|
render_md(site, q, "question")
|
|
for w in site.work:
|
|
for f in sorted(w.rglob("*.md")):
|
|
render_md(site, f, "work")
|
|
for files in site.other.values():
|
|
for f in files:
|
|
if f.suffix == ".md":
|
|
render_md(site, f, "other")
|
|
|
|
e = html.escape
|
|
|
|
def section(name: str, rows: list[str], empty: str, plain: bool = False) -> str:
|
|
head = f'<h2>{name} <span class="count">{len(rows)}</span></h2>'
|
|
if not rows:
|
|
return f'{head}<p class="empty">{empty}</p>'
|
|
cls = "rows plain" if plain else "rows"
|
|
return f'{head}<ul class="{cls}">{"".join(rows)}</ul>'
|
|
|
|
def row(item_id: str, href: str, title: str, meta: str = "", cls: str = "") -> str:
|
|
return (
|
|
f'<li><span class="id">{e(item_id)}</span>'
|
|
f'<span class="title {cls}"><a href="{href}">{e(title)}</a></span>'
|
|
f'<span class="meta">{meta}</span></li>'
|
|
)
|
|
|
|
docs = [
|
|
row(
|
|
d["id"],
|
|
quote(d["page"]),
|
|
d["title"],
|
|
status_tag(d["status"]),
|
|
"superseded" if d["status"].startswith("superseded") else "",
|
|
)
|
|
for d in site.docs
|
|
]
|
|
work = []
|
|
for w in site.work:
|
|
brief = w / "brief.md"
|
|
title = w.name[5:]
|
|
if brief.exists():
|
|
title, _ = split_title(frontmatter(brief.read_text())[1], title)
|
|
work.append(row(w.name[:4], quote(site.urls[w.name[:4]]), title))
|
|
questions = []
|
|
for q in site.questions:
|
|
meta, body = frontmatter(q.read_text(errors="replace"))
|
|
title, _ = split_title(body, q.stem)
|
|
created = e(meta.get("created", ""))
|
|
questions.append(row(q.name[:4], quote(site.page_of(q)), title, created))
|
|
|
|
other = []
|
|
for sub, files in site.other.items():
|
|
for f in files:
|
|
target = site.page_of(f) if f.suffix == ".md" else f.relative_to(notes)
|
|
portable = ""
|
|
if sub == "out" and f.suffix == ".html":
|
|
rel = f.relative_to(notes / "other" / "out").as_posix()
|
|
portable = (
|
|
f' <a class="tag info" href="{quote(f"{OUT}/{rel}")}">to send</a>'
|
|
)
|
|
other.append(
|
|
f'<li><span class="title"><a href="{quote(str(target))}">{e(f.name)}</a>'
|
|
f' <span class="tag">{sub}</span>{portable}</span></li>'
|
|
)
|
|
|
|
project = notes.parent.name
|
|
body = "\n".join(
|
|
[
|
|
# under serve this is the hub, under a plain http.server it is this index
|
|
'<nav class="back"><a href="/">← all notes</a></nav>',
|
|
'<header class="index-head">',
|
|
f"<h1>{e(project)} notes</h1>",
|
|
(
|
|
f"<p>{len(site.docs)} docs · {len(site.work)} in flight · "
|
|
f"{len(site.questions)} open questions · generated {date.today()}</p>"
|
|
),
|
|
"</header>",
|
|
'<div class="index">',
|
|
section("Docs", docs, "no docs"),
|
|
section("Work in flight", work, "nothing in flight"),
|
|
section("Open questions", questions, "no open questions"),
|
|
section("Other", other, "nothing here", plain=True) if other else "",
|
|
"</div>",
|
|
]
|
|
)
|
|
(notes / "index.html").write_text(
|
|
page(site, "index.html", f"{project} notes", body)
|
|
)
|
|
|
|
|
|
# serve: every project's notes on one port
|
|
|
|
|
|
SKIP_DIRS = {"node_modules", "target", "dist", "build", "vendor", "__pycache__"}
|
|
|
|
|
|
def find_notes(roots: list[Path], depth: int = 6) -> dict[str, Path]:
|
|
"""url prefix (path relative to its root) -> .notes dir, for every project under roots"""
|
|
found: dict[str, Path] = {}
|
|
|
|
def walk(d: Path, root: Path, left: int) -> None:
|
|
notes = d / ".notes"
|
|
if notes.is_dir() and (notes / "docs").is_dir():
|
|
found[d.relative_to(root).as_posix() or d.name] = notes
|
|
# a repo's own subdirectories are its code, not more projects
|
|
if left == 0 or ((d / ".git").exists() and d != root):
|
|
return
|
|
try:
|
|
children = sorted(d.iterdir())
|
|
except OSError:
|
|
return
|
|
for c in children:
|
|
skip = c.name.startswith(".") or c.name in SKIP_DIRS
|
|
if c.is_dir() and not c.is_symlink() and not skip:
|
|
walk(c, root, left - 1)
|
|
|
|
for root in roots:
|
|
if root.is_dir():
|
|
walk(root, root, depth)
|
|
return found
|
|
|
|
|
|
def hub_page(title: str, body: str) -> str:
|
|
return (
|
|
'<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n'
|
|
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
|
f"<title>{html.escape(title)}</title>\n"
|
|
'<link rel="stylesheet" href="/notes.css">\n</head>\n'
|
|
f'<body>\n<main class="page">\n{body}\n</main>\n</body>\n</html>\n'
|
|
)
|
|
|
|
|
|
def search_form(action: str, q: str = "") -> str:
|
|
return (
|
|
f'<form class="search" action="{action}" role="search">'
|
|
f'<input type="search" name="q" value="{html.escape(q)}" '
|
|
'placeholder="search this project\'s notes" aria-label="search notes">'
|
|
"</form>"
|
|
)
|
|
|
|
|
|
# filters the hub's project rows as you type, nothing leaves the page
|
|
FILTER_BOX = (
|
|
'<input class="filter" type="search" placeholder="filter projects" '
|
|
'aria-label="filter projects" autofocus>'
|
|
)
|
|
FILTER_SCRIPT = """<script>
|
|
const box = document.querySelector("input.filter");
|
|
const rows = [...document.querySelectorAll(".rows.hub li")];
|
|
box.addEventListener("input", () => {
|
|
const words = box.value.toLowerCase().split(/\\s+/).filter(Boolean);
|
|
for (const r of rows) {
|
|
const name = r.dataset.name;
|
|
r.hidden = !words.every((w) => name.includes(w));
|
|
}
|
|
document.querySelector(".hub-empty").hidden = rows.some((r) => !r.hidden);
|
|
});
|
|
box.addEventListener("keydown", (e) => {
|
|
const first = rows.find((r) => !r.hidden);
|
|
if (e.key === "Enter" && first) location.href = first.querySelector("a").href;
|
|
});
|
|
</script>"""
|
|
|
|
|
|
def hub(projects: dict[str, Path]) -> str:
|
|
e = html.escape
|
|
rows = []
|
|
for prefix, notes in sorted(projects.items()):
|
|
questions = len(list((notes / "questions").glob("q[0-9]*.md")))
|
|
work = len([p for p in (notes / "work").glob("w[0-9]*") if p.is_dir()])
|
|
docs = len(list((notes / "docs").glob("d[0-9]*")))
|
|
last = git(notes, "log", "-1", "--format=%cs", check=False).strip()
|
|
meta = f"{docs} docs · {work} in flight · {questions} open · {last or 'no commits'}"
|
|
rows.append(
|
|
f'<li data-name="{e(prefix.lower())}">'
|
|
f'<span class="title"><a href="/{quote(prefix)}/">{e(prefix)}</a></span>'
|
|
f'<span class="meta">{meta}</span></li>'
|
|
)
|
|
body = (
|
|
'<header class="index-head"><h1>notes</h1>'
|
|
f"<p>{len(projects)} projects · generated {date.today()}</p></header>"
|
|
+ (FILTER_BOX if rows else "")
|
|
+ '<div class="index"><h2>Projects</h2>'
|
|
+ (
|
|
f'<ul class="rows hub">{"".join(rows)}</ul>'
|
|
'<p class="empty hub-empty" hidden>no project matches</p>'
|
|
if rows
|
|
else '<p class="empty">no .notes/ found</p>'
|
|
)
|
|
+ "</div>"
|
|
# after the rows, so the script finds them
|
|
+ (FILTER_SCRIPT if rows else "")
|
|
)
|
|
return hub_page("notes", body)
|
|
|
|
|
|
def page_text(page: str) -> str:
|
|
"""the readable text of an html page, without its chrome, words kept apart"""
|
|
flags = re.DOTALL | re.IGNORECASE
|
|
page = re.sub(r"<(style|script|svg|nav|title)\b.*?</\1>", " ", page, flags=flags)
|
|
return html.unescape(re.sub(r"<[^>]+>", " ", page))
|
|
|
|
|
|
def searchable(notes: Path) -> list[tuple[str, str, str, str]]:
|
|
"""(id, title, url relative to .notes/, plain text) for every page worth searching"""
|
|
site = Site(notes)
|
|
out = []
|
|
for d in site.docs:
|
|
text = (notes / d["page"]).read_text(errors="replace")
|
|
out.append((d["id"], d["title"], d["page"], page_text(text)))
|
|
mds = [*site.questions, *(f for w in site.work for f in sorted(w.rglob("*.md")))]
|
|
mds += [f for files in site.other.values() for f in files if f.suffix == ".md"]
|
|
for f in mds:
|
|
_, body = frontmatter(f.read_text(errors="replace"))
|
|
title, _ = split_title(body, f.stem)
|
|
item = next((p[:4] for p in f.parts if re.match(r"[dwq]\d{3}-", p)), "")
|
|
out.append((item, title, site.page_of(f), body))
|
|
for files in site.other.values():
|
|
for f in files:
|
|
if f.suffix == ".html":
|
|
text = page_text(f.read_text(errors="replace"))
|
|
out.append(("", f.name, f.relative_to(notes).as_posix(), text))
|
|
return out
|
|
|
|
|
|
def search(prefix: str, notes: Path, q: str) -> str:
|
|
e = html.escape
|
|
terms = [t.lower() for t in q.split() if t]
|
|
hits = []
|
|
for item, title, url, text in searchable(notes) if terms else []:
|
|
low_title, low = title.lower(), " ".join(text.split()).lower()
|
|
if not all(t in low_title or t in low for t in terms):
|
|
continue
|
|
flat = " ".join(text.split())
|
|
# centre on the whole phrase if it occurs, else on the rarest word
|
|
at = low.find(" ".join(terms))
|
|
if at < 0:
|
|
rarest = min((t for t in terms if t in low), key=low.count, default="")
|
|
at = max(low.find(rarest), 0) if rarest else 0
|
|
start = max(0, at - 80)
|
|
snippet = e(("…" if start else "") + flat[start : at + 160] + "…")
|
|
for t in sorted(set(terms), key=len, reverse=True):
|
|
snippet = re.sub(
|
|
f"({re.escape(e(t))})", r"<mark>\1</mark>", snippet, flags=re.IGNORECASE
|
|
)
|
|
score = sum(t in low_title for t in terms) * 100 + sum(
|
|
low.count(t) for t in terms
|
|
)
|
|
hits.append((score, item, title, url, snippet))
|
|
write_index(notes)
|
|
hits.sort(key=lambda h: -h[0])
|
|
rows = "".join(
|
|
f'<li><span class="id">{e(item)}</span><span class="title">'
|
|
f'<a href="{quote(url)}">{e(title)}</a>'
|
|
f'<span class="snippet">{snippet}</span></span></li>'
|
|
for _, item, title, url, snippet in hits
|
|
)
|
|
found = (
|
|
f'<ul class="rows results">{rows}</ul>'
|
|
if rows
|
|
else '<p class="empty">nothing found</p>'
|
|
)
|
|
body = (
|
|
'<nav class="back"><a href="./">← notes</a></nav>'
|
|
f'<header class="index-head"><h1>{e(prefix)}: search</h1>'
|
|
f"<p>{len(hits)} results for “{e(q)}”</p></header>"
|
|
+ search_form("search", q)
|
|
+ f'<div class="index">{found}</div>'
|
|
)
|
|
return hub_page(f"{q} · {prefix} notes", body)
|
|
|
|
|
|
def serve(roots: list[Path], host: str, port: int) -> None:
|
|
import http.server
|
|
from urllib.parse import parse_qs, unquote, urlsplit
|
|
|
|
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
def guess_type(self, path: str) -> str:
|
|
if str(path).endswith(".md"):
|
|
return "text/plain; charset=utf-8"
|
|
return super().guess_type(path)
|
|
|
|
def send_text(self, body: str, ctype: str) -> None:
|
|
data = body.encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", ctype)
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
def do_GET(self) -> None:
|
|
path = unquote(urlsplit(self.path).path)
|
|
if path in ("/", "/index.html"):
|
|
return self.send_text(
|
|
hub(find_notes(roots)), "text/html; charset=utf-8"
|
|
)
|
|
if path == "/notes.css":
|
|
return self.send_text(CSS.read_text(), "text/css; charset=utf-8")
|
|
projects = find_notes(roots)
|
|
prefix = max(
|
|
(p for p in projects if path == f"/{p}" or path.startswith(f"/{p}/")),
|
|
key=len,
|
|
default=None,
|
|
)
|
|
if prefix is None:
|
|
return self.send_error(404, "no such project")
|
|
if path == f"/{prefix}":
|
|
self.send_response(301)
|
|
self.send_header("Location", f"/{quote(prefix)}/")
|
|
self.end_headers()
|
|
return None
|
|
rest = path[len(prefix) + 2 :]
|
|
notes = projects[prefix]
|
|
if rest == "search":
|
|
q = parse_qs(urlsplit(self.path).query).get("q", [""])[0]
|
|
body = search(prefix, notes, q)
|
|
return self.send_text(body, "text/html; charset=utf-8")
|
|
if rest in ("", "index.html"):
|
|
# rebuilt on every visit, so a session that forgot to regenerate never shows stale;
|
|
# the search box only works under serve, so it is added here, not in the file
|
|
write_index(notes)
|
|
page = (notes / "index.html").read_text()
|
|
page = page.replace("</header>", "</header>" + search_form("search"), 1)
|
|
return self.send_text(page, "text/html; charset=utf-8")
|
|
self.serve_from = projects[prefix]
|
|
self.path = "/" + rest
|
|
return super().do_GET()
|
|
|
|
def translate_path(self, path: str) -> str:
|
|
base = self.serve_from.resolve()
|
|
target = (base / unquote(urlsplit(path).path).lstrip("/")).resolve()
|
|
return str(target if target.is_relative_to(base) else base / "__outside__")
|
|
|
|
def log_message(self, format: str, *args) -> None:
|
|
pass
|
|
|
|
server = http.server.ThreadingHTTPServer((host, port), Handler)
|
|
print(f"serving notes from {', '.join(map(str, roots))} on http://{host}:{port}/")
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
|
|
|
|
def default_roots() -> list[Path]:
|
|
import os
|
|
|
|
env = os.environ.get("NOTES_ROOTS")
|
|
if env:
|
|
return [Path(p).expanduser() for p in env.split(os.pathsep) if p]
|
|
return [Path.home() / "git"]
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
parser.add_argument("--root")
|
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
sub.add_parser("init")
|
|
p_next = sub.add_parser("next")
|
|
p_next.add_argument("letter", choices=sorted(BUCKETS))
|
|
sub.add_parser("index")
|
|
p_commit = sub.add_parser("commit")
|
|
p_commit.add_argument("subject")
|
|
p_serve = sub.add_parser("serve")
|
|
p_serve.add_argument("--port", type=int, default=8765)
|
|
p_serve.add_argument("--host", default="127.0.0.1")
|
|
p_serve.add_argument("--roots", nargs="*", type=Path)
|
|
args = parser.parse_args()
|
|
|
|
if args.cmd == "serve":
|
|
serve(args.roots or default_roots(), args.host, args.port)
|
|
return
|
|
|
|
notes = project_root(args.root) / ".notes"
|
|
if args.cmd == "init":
|
|
init(notes)
|
|
return
|
|
if not notes.exists():
|
|
sys.exit(f"{notes} does not exist, run init first")
|
|
if args.cmd == "next":
|
|
print(next_id(notes, args.letter))
|
|
elif args.cmd == "index":
|
|
write_index(notes)
|
|
print(notes / "index.html")
|
|
elif args.cmd == "commit":
|
|
commit(notes, args.subject)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|