Add personal Markdown mount CLI

This commit is contained in:
virgil
2026-06-09 10:27:30 -07:00
parent 319f379129
commit 2ed335ed28
3 changed files with 382 additions and 14 deletions

View File

@@ -44,6 +44,7 @@ STATUS_URL="$STATUS_URL"
SESSION_DIR="\${SECTION0_SESSION_DIR:-\$HOME/.config/section0-docs}"
SESSION_PATH="\$SESSION_DIR/session.json"
EVENTS_PATH="\$SESSION_DIR/events.jsonl"
MOUNTS_PATH="\$SESSION_DIR/mounts.json"
usage() {
cat <<USAGE
@@ -63,6 +64,20 @@ Commands:
wait show AFFiNE projection state for a pushed commit
push-test prove write access with a temporary remote branch
trace show local/origin state and recent helper events
mount list
list local personal Markdown projections
mount add NAME PATH
remember a local Markdown folder as a personal read-only projection
mount plan NAME
print the projection plan that would be sent to the home-lab
mount sync NAME
send the projection plan to the authenticated home-lab endpoint
mount status NAME
show the local projection declaration and last sync receipt
mount unmount NAME
ask the home-lab to remove projected AFFiNE docs for this mount
mount remove NAME
remove the local projection declaration
help show this help
Examples:
@@ -75,12 +90,13 @@ Examples:
section0-docs status
section0-docs push-test
section0-docs trace
section0-docs mount add research ~/Documents/Obsidian/Research
section0-docs mount sync research
MESSAGE="Update concept notes" section0-docs commit
section0-docs push
section0-docs wait
AFFiNE is read-only for these docs. Make lasting changes in Markdown.
AFFiNE projection is currently paused until the native writer is ready.
USAGE
}
@@ -335,7 +351,9 @@ access=json.loads(sys.argv[2])
print(json.dumps({
"authenticated": True,
"serverUrl": sys.argv[3],
"accessToken": token.get("accessToken", ""),
"authentik": token.get("authentik", {}),
"affine": token.get("affine", {}),
"author": access.get("author", {}),
"remote": access.get("remote", ""),
"credentialUser": access.get("git", {}).get("username", "")
@@ -348,6 +366,290 @@ print(json.dumps({
json_log "auth.login.complete" "\$author_email"
}
require_session() {
if [ ! -f "\$SESSION_PATH" ]; then
echo "not authenticated; run: section0-docs auth login" >&2
exit 1
fi
}
session_value() {
require_session
json_get "\$1" < "\$SESSION_PATH"
}
mounts_init() {
mkdir -p "\$SESSION_DIR"
if [ ! -f "\$MOUNTS_PATH" ]; then
printf '{\n "mounts": {}\n}\n' > "\$MOUNTS_PATH"
chmod 600 "\$MOUNTS_PATH"
fi
}
mount_add() {
need_python
require_session
mounts_init
name="\${1:-}"
source="\${2:-}"
[ -n "\$name" ] && [ -n "\$source" ] || {
echo "usage: section0-docs mount add NAME PATH" >&2
exit 1
}
if [ ! -d "\$source" ]; then
echo "mount source is not a directory: \$source" >&2
exit 1
fi
abs_source="\$(cd "\$source" && pwd)"
username="\$(session_value authentik.username)"
[ -n "\$username" ] || username="\$(session_value authentik.email | sed 's/@.*//')"
workspace_id="\$(session_value affine.workspaceId)"
namespace="mount/\$username/\$name"
python3 - "\$MOUNTS_PATH" "\$name" "\$abs_source" "\$namespace" "\$workspace_id" <<'PY'
import datetime, json, sys
path, name, source, namespace, workspace_id = sys.argv[1:6]
now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
with open(path) as f:
data = json.load(f)
mounts = data.setdefault("mounts", {})
mounts[name] = {
"name": name,
"source": source,
"namespace": namespace,
"workspaceId": workspace_id,
"include": ["**/*.md"],
"exclude": [".git/**", ".obsidian/**", ".trash/**", "node_modules/**"],
"createdAt": mounts.get(name, {}).get("createdAt") or now,
"updatedAt": now,
}
with open(path, "w") as f:
json.dump(data, f, indent=2)
f.write("\n")
PY
chmod 600 "\$MOUNTS_PATH"
echo "Mount saved: \$name"
echo "Source: \$abs_source"
echo "AFFiNE namespace: \$namespace"
json_log "mount.add" "\$name"
}
mount_list() {
need_python
mounts_init
python3 - "\$MOUNTS_PATH" <<'PY'
import json, sys
with open(sys.argv[1]) as f:
mounts = json.load(f).get("mounts", {})
if not mounts:
print("no personal Markdown mounts; add one with: section0-docs mount add NAME PATH")
raise SystemExit
for name, mount in sorted(mounts.items()):
print(f"{name}\t{mount.get('source','')}\t{mount.get('namespace','')}")
PY
}
mount_status() {
need_python
mounts_init
name="\${1:-}"
[ -n "\$name" ] || {
echo "usage: section0-docs mount status NAME" >&2
exit 1
}
python3 - "\$MOUNTS_PATH" "\$name" <<'PY'
import json, sys
with open(sys.argv[1]) as f:
mount = json.load(f).get("mounts", {}).get(sys.argv[2])
if not mount:
print(f"mount not found: {sys.argv[2]}", file=sys.stderr)
raise SystemExit(1)
print(json.dumps(mount, indent=2))
PY
}
mount_remove() {
need_python
mounts_init
name="\${1:-}"
[ -n "\$name" ] || {
echo "usage: section0-docs mount remove NAME" >&2
exit 1
}
python3 - "\$MOUNTS_PATH" "\$name" <<'PY'
import json, sys
path, name = sys.argv[1:3]
with open(path) as f:
data = json.load(f)
if name not in data.get("mounts", {}):
print(f"mount not found: {name}", file=sys.stderr)
raise SystemExit(1)
del data["mounts"][name]
with open(path, "w") as f:
json.dump(data, f, indent=2)
f.write("\n")
PY
echo "Removed local mount declaration: \$name"
json_log "mount.remove" "\$name"
}
mount_plan() {
need_python
require_session
mounts_init
name="\${1:-}"
[ -n "\$name" ] || {
echo "usage: section0-docs mount plan NAME" >&2
exit 1
}
python3 - "\$MOUNTS_PATH" "\$SESSION_PATH" "\$name" <<'PY'
import fnmatch, hashlib, json, os, pathlib, sys
mounts_path, session_path, name = sys.argv[1:4]
with open(mounts_path) as f:
mount = json.load(f).get("mounts", {}).get(name)
if not mount:
print(f"mount not found: {name}", file=sys.stderr)
raise SystemExit(1)
with open(session_path) as f:
session = json.load(f)
source = pathlib.Path(mount["source"])
if not source.is_dir():
print(f"mount source is not a directory: {source}", file=sys.stderr)
raise SystemExit(1)
include = mount.get("include") or ["**/*.md"]
exclude = mount.get("exclude") or []
docs = []
def matches(patterns, rel):
for pattern in patterns:
if fnmatch.fnmatch(rel, pattern):
return True
if pattern.startswith("**/") and fnmatch.fnmatch(rel, pattern[3:]):
return True
return False
for path in sorted(source.rglob("*.md")):
if not path.is_file():
continue
rel = path.relative_to(source).as_posix()
if not matches(include, rel):
continue
if any(fnmatch.fnmatch(rel, pattern) or rel.startswith(pattern.removesuffix("/**") + "/") for pattern in exclude):
continue
markdown = path.read_text(encoding="utf-8")
digest = hashlib.sha256(markdown.encode("utf-8")).hexdigest()
stable = hashlib.sha256(f"{session.get('authentik', {}).get('subject','')}:{mount['namespace']}:{rel}".encode("utf-8")).hexdigest()[:24]
title = path.stem.replace("_", " ").replace("-", " ").strip() or rel
docs.append({
"stableSourceId": stable,
"operation": "update",
"sourcePath": rel,
"sourceSha256": digest,
"title": title,
"markdown": markdown,
"affine": {
"docId": f"obs-{stable}",
"namespace": mount["namespace"],
},
})
plan = {
"metadata": {
"name": name,
"sourceModel": "personal-local-markdown",
"custody": "local-user",
"readOnlyIntent": True,
},
"owner": {
"authentik": session.get("authentik", {}),
"affine": session.get("affine", {}),
},
"target": {
"workspaceId": mount.get("workspaceId") or session.get("affine", {}).get("workspaceId"),
"namespace": mount["namespace"],
},
"summary": {
"documents": len(docs),
"source": str(source),
},
"projectedDocuments": docs,
}
print(json.dumps({"plan": plan}, indent=2))
PY
}
mount_sync() {
need_python
require_session
name="\${1:-}"
[ -n "\$name" ] || {
echo "usage: section0-docs mount sync NAME" >&2
exit 1
}
access_token="\$(session_value accessToken)"
[ -n "\$access_token" ] || {
echo "saved session has no access token; run: section0-docs auth login" >&2
exit 1
}
tmp_plan="\$(mktemp)"
mount_plan "\$name" > "\$tmp_plan"
doc_count="\$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))["plan"].get("projectedDocuments", [])))' "\$tmp_plan")"
echo "Submitting personal Markdown projection:"
echo " mount: \$name"
echo " docs: \$doc_count"
response="\$(curl -fsS -X POST -H "Authorization: Bearer \$access_token" -H "Content-Type: application/json" --data-binary "@\$tmp_plan" "\$SERVER_URL/sync")"
rm -f "\$tmp_plan"
printf "%s\n" "\$response"
python3 - "\$MOUNTS_PATH" "\$name" "\$response" <<'PY'
import datetime, json, sys
path, name, response = sys.argv[1:4]
now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
with open(path) as f:
data = json.load(f)
mount = data.setdefault("mounts", {}).setdefault(name, {"name": name})
mount["lastSyncAt"] = now
try:
mount["lastSync"] = json.loads(response)
except Exception:
mount["lastSync"] = {"raw": response}
with open(path, "w") as f:
json.dump(data, f, indent=2)
f.write("\n")
PY
json_log "mount.sync" "\$name"
}
mount_unmount() {
need_python
require_session
mounts_init
name="\${1:-}"
[ -n "\$name" ] || {
echo "usage: section0-docs mount unmount NAME" >&2
exit 1
}
access_token="\$(session_value accessToken)"
[ -n "\$access_token" ] || {
echo "saved session has no access token; run: section0-docs auth login" >&2
exit 1
}
payload="\$(python3 - "\$MOUNTS_PATH" "\$SESSION_PATH" "\$name" <<'PY'
import json, sys
with open(sys.argv[1]) as f:
mount = json.load(f).get("mounts", {}).get(sys.argv[3])
if not mount:
print(f"mount not found: {sys.argv[3]}", file=sys.stderr)
raise SystemExit(1)
with open(sys.argv[2]) as f:
session = json.load(f)
print(json.dumps({
"workspaceId": mount.get("workspaceId") or session.get("affine", {}).get("workspaceId"),
"namespace": mount["namespace"],
}))
PY
)"
response="\$(printf "%s" "\$payload" | curl -fsS -X POST -H "Authorization: Bearer \$access_token" -H "Content-Type: application/json" --data-binary @- "\$SERVER_URL/unmount")"
printf "%s\n" "\$response"
json_log "mount.unmount" "\$name"
}
case "\${1:-help}" in
auth)
case "\${2:-}" in
@@ -466,6 +768,35 @@ case "\${1:-help}" in
echo "no events yet"
fi
;;
mount)
case "\${2:-}" in
list)
mount_list
;;
add)
mount_add "\${3:-}" "\${4:-}"
;;
plan)
mount_plan "\${3:-}"
;;
sync)
mount_sync "\${3:-}"
;;
status)
mount_status "\${3:-}"
;;
unmount)
mount_unmount "\${3:-}"
;;
remove)
mount_remove "\${3:-}"
;;
*)
echo "usage: section0-docs mount <list|add|plan|sync|status|unmount|remove>" >&2
exit 1
;;
esac
;;
help|--help|-h)
usage
;;