feat(claude): track agents and the matej and notes skills
This commit is contained in:
788
claude/skills/notes/notes.py
Executable file
788
claude/skills/notes/notes.py
Executable file
@@ -0,0 +1,788 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user