Add Start/End work transitions, capture field, and PWA support

This commit is contained in:
2026-08-02 16:24:16 -04:00
parent b737200b21
commit d5f144c735
8 changed files with 309 additions and 16 deletions

177
main.py
View File

@ -52,12 +52,24 @@ TODO_KEYWORD_RE = re.compile(r"^(" + "|".join(TODO_KEYWORDS) + r")\b\s*(.*)", re
DEFAULT_CONFIG_PATH = ROOT_DIR / "config.yaml" DEFAULT_CONFIG_PATH = ROOT_DIR / "config.yaml"
def excluded_dir_names() -> set[str]:
"""Directories to skip while scanning, from defaults plus DIRS_TO_EXCLUDE env var."""
excluded = {".git", ".venv", "venv", "__pycache__"}
raw = os.environ.get("DIRS_TO_EXCLUDE", "")
for name in raw.split(","):
name = name.strip()
if name:
excluded.add(name)
return excluded
def scan_org_files(base_dir: Path) -> list[OrgFile]: def scan_org_files(base_dir: Path) -> list[OrgFile]:
"""Recursively find .org files under base_dir and return their contents.""" """Recursively find .org files under base_dir and return their contents."""
org_files: list[OrgFile] = [] org_files: list[OrgFile] = []
excluded = excluded_dir_names()
for root, dirs, files in os.walk(base_dir): for root, dirs, files in os.walk(base_dir):
dirs[:] = [d for d in dirs if d not in {".git", ".venv", "venv", "__pycache__"}] dirs[:] = [d for d in dirs if d not in excluded]
root_path = Path(root) root_path = Path(root)
for filename in files: for filename in files:
if filename.lower().endswith(".org"): if filename.lower().endswith(".org"):
@ -445,13 +457,16 @@ def render_org_to_html(
for t in heading_tags for t in heading_tags
) )
html_lines.append(f"<div class='org-tags'>{tag_spans}</div>") html_lines.append(f"<div class='org-tags'>{tag_spans}</div>")
if keyword == "TODO" and show_webhook: if keyword and show_webhook:
safe_file = html.escape(source_relative_path, quote=True) safe_file = html.escape(source_relative_path, quote=True)
safe_heading = html.escape(bare_text, quote=True) safe_heading = html.escape(bare_text, quote=True)
html_lines.append( html_lines.append(
f"<button class='webhook-send-btn' type='button' " f"<button class='webhook-send-btn' type='button' "
f"data-file='{safe_file}' data-heading='{safe_heading}'>" f"data-file='{safe_file}' data-heading='{safe_heading}' "
f"Start work</button>" f"data-state='STRT'>Start work</button> "
f"<button class='webhook-send-btn' type='button' "
f"data-file='{safe_file}' data-heading='{safe_heading}' "
f"data-state='DONE'>End work</button>"
) )
continue continue
@ -484,7 +499,7 @@ _NOTE_TAKEN_RE = re.compile(
) )
def extract_heading_data(content: str, heading_text: str, file_id: str = "") -> dict | None: def extract_heading_data(content: str, heading_text: str, file_id: str = "", state: str = "STRT") -> dict | None:
"""Find a heading in *content* by its text and extract webhook-ready data.""" """Find a heading in *content* by its text and extract webhook-ready data."""
lines = content.splitlines() lines = content.splitlines()
target_idx: int | None = None target_idx: int | None = None
@ -512,7 +527,7 @@ def extract_heading_data(content: str, heading_text: str, file_id: str = "") ->
full_rest = hm.group(2) if hm else "" full_rest = hm.group(2) if hm else ""
kw_match = TODO_KEYWORD_RE.match(full_rest) kw_match = TODO_KEYWORD_RE.match(full_rest)
state = kw_match.group(1) if kw_match else "" heading_keyword = kw_match.group(1) if kw_match else ""
description = kw_match.group(2) if kw_match else full_rest description = kw_match.group(2) if kw_match else full_rest
tag_match = _ORG_TAG_RE.search(description) tag_match = _ORG_TAG_RE.search(description)
@ -536,16 +551,19 @@ def extract_heading_data(content: str, heading_text: str, file_id: str = "") ->
timestamps: list[str] = [] timestamps: list[str] = []
in_drawer: str | None = None in_drawer: str | None = None
drawer_lines: list[str] = [] drawer_lines: list[str] = []
properties_seen = False
for sl in sub_lines: for sl in sub_lines:
stripped = sl.strip() stripped = sl.strip()
if in_drawer is not None: if in_drawer is not None:
if _DRAWER_END_RE.match(stripped): if _DRAWER_END_RE.match(stripped):
if in_drawer == "PROPERTIES": if in_drawer == "PROPERTIES":
for dl in drawer_lines: if not properties_seen:
pm = _PROP_KV_RE.match(dl) for dl in drawer_lines:
if pm: pm = _PROP_KV_RE.match(dl)
properties[pm.group(1)] = pm.group(2) if pm:
properties[pm.group(1)] = pm.group(2)
properties_seen = True
else: else:
drawers[in_drawer] = list(drawer_lines) drawers[in_drawer] = list(drawer_lines)
in_drawer = None in_drawer = None
@ -571,7 +589,7 @@ def extract_heading_data(content: str, heading_text: str, file_id: str = "") ->
return { return {
"description": description, "description": description,
"labels": tags, "labels": tags,
"state": "STRT", "state": state,
"timestamps": timestamps, "timestamps": timestamps,
"notes": notes, "notes": notes,
"drawers": drawers, "drawers": drawers,
@ -583,6 +601,65 @@ def extract_heading_data(content: str, heading_text: str, file_id: str = "") ->
} }
def add_to_inbox(content: str, text: str) -> str:
"""Append a '** TODO <text>' item under a top-level '* Inbox' section.
Creates the Inbox section at the bottom of the file if it does not exist.
"""
text = text.strip()
if not text:
return content
new_task = f"** TODO {text}"
inbox_re = re.compile(r"^\* Inbox\s*$")
top_level_re = re.compile(r"^\*\s")
lines = content.rstrip("\n").splitlines()
inbox_idx: int | None = None
for idx, line in enumerate(lines):
if inbox_re.match(line):
inbox_idx = idx
if inbox_idx is None:
lines.append("* Inbox")
inbox_idx = len(lines) - 1
end = len(lines)
for idx in range(inbox_idx + 1, len(lines)):
if top_level_re.match(lines[idx]):
end = idx
break
lines.insert(end, new_task)
return "\n".join(lines) + "\n"
def update_heading_keyword(content: str, heading_text: str, new_keyword: str) -> str | None:
"""Set the TODO keyword on a heading (identified by its bare text) in *content*.
Returns the updated content, or None if the heading could not be found.
"""
lines = content.splitlines()
for idx, raw_line in enumerate(lines):
hm = _HEADING_RE.match(raw_line)
if not hm:
continue
rest = hm.group(2)
kw_match = TODO_KEYWORD_RE.match(rest)
bare_title = kw_match.group(2) if kw_match else rest
tag_match = _ORG_TAG_RE.search(bare_title)
bare_title_no_tags = bare_title[: tag_match.start()] if tag_match else bare_title
if bare_title_no_tags.strip() != heading_text.strip():
continue
heading_marker = hm.group(1)
if kw_match:
new_rest = f"{new_keyword} {kw_match.group(2)}".rstrip()
else:
new_rest = f"{new_keyword} {rest}"
lines[idx] = f"{heading_marker} {new_rest}"
return "\n".join(lines)
return None
def _parse_org_ts_to_iso(raw: str) -> str: def _parse_org_ts_to_iso(raw: str) -> str:
"""Convert an org timestamp body like '2026-02-12 Thu 16:00' to ISO 8601.""" """Convert an org timestamp body like '2026-02-12 Thu 16:00' to ISO 8601."""
m = re.match( m = re.match(
@ -712,10 +789,18 @@ def build_index_page(
"</form>" "</form>"
) )
else: else:
capture_form = (
"<form class='capture-form' method='post' action='/capture'>"
f"<input type='hidden' name='file' value='{html.escape(selected.relative_path, quote=True)}'>"
"<input class='capture-input' type='text' name='text' placeholder='Capture a task\u2026' autocomplete='off'>"
"<button class='capture-btn' type='submit'>Capture</button>"
"</form>"
)
body = ( body = (
f"<h2>{html.escape(selected.relative_path)}</h2>" f"<h2>{html.escape(selected.relative_path)}</h2>"
f"<div class='toolbar'>{mode_toggle}</div>" f"<div class='toolbar'>{mode_toggle}</div>"
f"{status_html}" f"{status_html}"
f"{capture_form}"
f"<article class='org-content'>{render_org_to_html(selected.content, selected.relative_path, known_paths, id_to_path, show_webhook=show_webhook)}</article>" f"<article class='org-content'>{render_org_to_html(selected.content, selected.relative_path, known_paths, id_to_path, show_webhook=show_webhook)}</article>"
) )
@ -781,6 +866,10 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "")
self.serve_static(parsed.path) self.serve_static(parsed.path)
return return
if parsed.path == "/sw.js":
self.serve_service_worker()
return
if parsed.path != "/": if parsed.path != "/":
self.send_error(404, "Not found") self.send_error(404, "Not found")
return return
@ -810,6 +899,9 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "")
elif error == "missing": elif error == "missing":
status_message = "Select a valid .org file first." status_message = "Select a valid .org file first."
status_level = "error" status_level = "error"
elif error == "empty":
status_message = "Enter some text to capture."
status_level = "error"
elif error == "write": elif error == "write":
status_message = "Could not write this file." status_message = "Could not write this file."
status_level = "error" status_level = "error"
@ -836,6 +928,9 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "")
if parsed.path == "/webhook": if parsed.path == "/webhook":
self.handle_webhook() self.handle_webhook()
return return
if parsed.path == "/capture":
self.handle_capture()
return
if parsed.path != "/edit": if parsed.path != "/edit":
self.send_error(404, "Not found") self.send_error(404, "Not found")
return return
@ -860,6 +955,31 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "")
self.redirect_with_query("/", {"file": selected.relative_path, "edit": "1", "saved": "1"}) self.redirect_with_query("/", {"file": selected.relative_path, "edit": "1", "saved": "1"})
def handle_capture(self) -> None:
content_length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(content_length).decode("utf-8", errors="replace")
params = parse_qs(body)
selected_path = params.get("file", [None])[0]
text = params.get("text", [""])[0].strip()
org_files = scan_org_files(base_dir)
selected = find_org_file(org_files, selected_path)
if selected is None:
self.redirect_with_query("/", {"error": "missing"})
return
if not text:
self.redirect_with_query("/", {"file": selected.relative_path, "error": "empty"})
return
updated = add_to_inbox(selected.content, text)
try:
selected.path.write_text(updated, encoding="utf-8")
except OSError:
self.redirect_with_query("/", {"file": selected.relative_path, "error": "write"})
return
self.redirect_with_query("/", {"file": selected.relative_path, "saved": "1"})
def handle_webhook(self) -> None: def handle_webhook(self) -> None:
if not webhook_url: if not webhook_url:
self.send_json_response(400, {"error": "No webhook_url configured"}) self.send_json_response(400, {"error": "No webhook_url configured"})
@ -879,13 +999,18 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "")
self.send_json_response(400, {"error": "Missing file or heading"}) self.send_json_response(400, {"error": "Missing file or heading"})
return return
state = payload.get("state", "STRT")
if state not in ("STRT", "DONE"):
self.send_json_response(400, {"error": "Invalid state"})
return
org_files = scan_org_files(base_dir) org_files = scan_org_files(base_dir)
org_file = find_org_file(org_files, file_path) org_file = find_org_file(org_files, file_path)
if org_file is None: if org_file is None:
self.send_json_response(404, {"error": "File not found"}) self.send_json_response(404, {"error": "File not found"})
return return
data = extract_heading_data(org_file.content, heading_text, file_id=org_file.file_id or "") data = extract_heading_data(org_file.content, heading_text, file_id=org_file.file_id or "", state=state)
if data is None: if data is None:
self.send_json_response(404, {"error": "Heading not found"}) self.send_json_response(404, {"error": "Heading not found"})
return return
@ -896,11 +1021,24 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "")
if webhook_token: if webhook_token:
headers["Authorization"] = f"Token {webhook_token}" headers["Authorization"] = f"Token {webhook_token}"
new_keyword = "DONE" if state == "DONE" else "STRT"
updated_content = update_heading_keyword(org_file.content, heading_text, new_keyword)
file_updated = False
if updated_content is not None:
try:
org_file.path.write_text(updated_content, encoding="utf-8")
file_updated = True
except OSError:
file_updated = False
try: try:
req = Request(webhook_url, data=encoded_data, headers=headers, method="POST") req = Request(webhook_url, data=encoded_data, headers=headers, method="POST")
with urlopen(req, timeout=10) as resp: with urlopen(req, timeout=10) as resp:
resp_body = resp.read().decode("utf-8", errors="replace") resp_body = resp.read().decode("utf-8", errors="replace")
self.send_json_response(200, {"ok": True, "response": resp_body}) result: dict = {"ok": True, "response": resp_body}
if not file_updated:
result["error"] = "Could not update heading state in file"
self.send_json_response(200, result)
except Exception as exc: except Exception as exc:
self.send_json_response(502, {"error": f"Webhook failed: {exc}"}) self.send_json_response(502, {"error": f"Webhook failed: {exc}"})
@ -937,6 +1075,19 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "")
self.end_headers() self.end_headers()
self.wfile.write(data) self.wfile.write(data)
def serve_service_worker(self) -> None:
candidate = STATIC_DIR / "sw.js"
if not candidate.is_file():
self.send_error(404, "Not found")
return
data = candidate.read_bytes()
self.send_response(200)
self.send_header("Content-Type", "application/javascript")
self.send_header("Service-Worker-Allowed", "/")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, fmt: str, *args) -> None: def log_message(self, fmt: str, *args) -> None:
return return

BIN
static/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
static/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,29 @@
{
"name": "Org Web Adapter",
"short_name": "OrgWeb",
"description": "Browse, edit, and capture notes in your Org files.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0f1720",
"theme_color": "#0f1720",
"icons": [
{
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/static/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View File

@ -386,6 +386,43 @@ main h2 {
filter: brightness(1.08); filter: brightness(1.08);
} }
.capture-form {
display: flex;
gap: 0.5rem;
margin: 0 0 0.8rem;
}
.capture-input {
flex: 1;
min-width: 0;
padding: 0.55rem 0.75rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--panel);
color: var(--ink);
font-size: 0.95rem;
}
.capture-input:focus {
outline: none;
border-color: var(--accent);
}
.capture-btn {
border: 1px solid var(--accent);
border-radius: 0.55rem;
background: var(--accent);
color: #ffffff;
padding: 0.5rem 0.95rem;
font-weight: 600;
cursor: pointer;
}
.capture-btn:hover {
filter: brightness(1.08);
}
.org-content { .org-content {
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
@ -618,9 +655,15 @@ body[data-theme="dark"] .webhook-send-btn.webhook-sent {
overflow: visible; overflow: visible;
} }
main {
order: 1;
}
nav { nav {
order: 2;
border-right: none; border-right: none;
border-bottom: 1px solid var(--line); border-bottom: none;
border-top: 1px solid var(--line);
display: flex; display: flex;
} }
@ -631,6 +674,7 @@ body[data-theme="dark"] .webhook-send-btn.webhook-sent {
} }
.backlinks-pane { .backlinks-pane {
order: 3;
border-left: none; border-left: none;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }

53
static/sw.js Normal file
View File

@ -0,0 +1,53 @@
const CACHE = "orgweb-v1";
const PRECACHE = [
"/",
"/static/style.css",
"/static/manifest.webmanifest",
"/static/icon-192.png",
"/static/icon-512.png",
"/static/icon-512-maskable.png",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE)
.then((cache) => cache.addAll(PRECACHE))
.then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.origin !== location.origin) return;
if (event.request.mode === "navigate") {
event.respondWith(
fetch(event.request).catch(() => caches.match("/"))
);
return;
}
event.respondWith(
caches.match(event.request).then(
(cached) =>
cached ||
fetch(event.request).then((resp) => {
const copy = resp.clone();
caches.open(CACHE).then((cache) => cache.put(event.request, copy));
return resp;
})
)
);
});

View File

@ -4,6 +4,13 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Org Web Adapter</title> <title>Org Web Adapter</title>
<meta name="theme-color" content="#0f1720">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="/static/manifest.webmanifest">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<link rel="icon" type="image/png" href="/static/icon-192.png">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
<body> <body>
@ -316,12 +323,14 @@
if (!btn) return; if (!btn) return;
const file = btn.getAttribute("data-file"); const file = btn.getAttribute("data-file");
const heading = btn.getAttribute("data-heading"); const heading = btn.getAttribute("data-heading");
const state = btn.getAttribute("data-state") || "STRT";
const label = btn.textContent.trim();
btn.disabled = true; btn.disabled = true;
btn.textContent = "Sending\u2026"; btn.textContent = "Sending\u2026";
fetch("/webhook", { fetch("/webhook", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ file: file, heading: heading }), body: JSON.stringify({ file: file, heading: heading, state: state }),
}) })
.then(function (resp) { return resp.json(); }) .then(function (resp) { return resp.json(); })
.then(function (data) { .then(function (data) {
@ -340,12 +349,19 @@
.finally(function () { .finally(function () {
setTimeout(function () { setTimeout(function () {
btn.disabled = false; btn.disabled = false;
btn.textContent = "Start work"; btn.textContent = label;
btn.classList.remove("webhook-sent", "webhook-error"); btn.classList.remove("webhook-sent", "webhook-error");
}, 3000); }, 3000);
}); });
}); });
})(); })();
</script> </script>
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {
navigator.serviceWorker.register("/sw.js").catch(function () {});
});
}
</script>
</body> </body>
</html> </html>