Add Start/End work transitions, capture field, and PWA support
This commit is contained in:
177
main.py
177
main.py
@ -52,12 +52,24 @@ TODO_KEYWORD_RE = re.compile(r"^(" + "|".join(TODO_KEYWORDS) + r")\b\s*(.*)", re
|
||||
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]:
|
||||
"""Recursively find .org files under base_dir and return their contents."""
|
||||
org_files: list[OrgFile] = []
|
||||
excluded = excluded_dir_names()
|
||||
|
||||
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)
|
||||
for filename in files:
|
||||
if filename.lower().endswith(".org"):
|
||||
@ -445,13 +457,16 @@ def render_org_to_html(
|
||||
for t in heading_tags
|
||||
)
|
||||
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_heading = html.escape(bare_text, quote=True)
|
||||
html_lines.append(
|
||||
f"<button class='webhook-send-btn' type='button' "
|
||||
f"data-file='{safe_file}' data-heading='{safe_heading}'>"
|
||||
f"Start work</button>"
|
||||
f"data-file='{safe_file}' data-heading='{safe_heading}' "
|
||||
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
|
||||
|
||||
@ -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."""
|
||||
lines = content.splitlines()
|
||||
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 ""
|
||||
|
||||
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
|
||||
|
||||
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] = []
|
||||
in_drawer: str | None = None
|
||||
drawer_lines: list[str] = []
|
||||
properties_seen = False
|
||||
|
||||
for sl in sub_lines:
|
||||
stripped = sl.strip()
|
||||
if in_drawer is not None:
|
||||
if _DRAWER_END_RE.match(stripped):
|
||||
if in_drawer == "PROPERTIES":
|
||||
for dl in drawer_lines:
|
||||
pm = _PROP_KV_RE.match(dl)
|
||||
if pm:
|
||||
properties[pm.group(1)] = pm.group(2)
|
||||
if not properties_seen:
|
||||
for dl in drawer_lines:
|
||||
pm = _PROP_KV_RE.match(dl)
|
||||
if pm:
|
||||
properties[pm.group(1)] = pm.group(2)
|
||||
properties_seen = True
|
||||
else:
|
||||
drawers[in_drawer] = list(drawer_lines)
|
||||
in_drawer = None
|
||||
@ -571,7 +589,7 @@ def extract_heading_data(content: str, heading_text: str, file_id: str = "") ->
|
||||
return {
|
||||
"description": description,
|
||||
"labels": tags,
|
||||
"state": "STRT",
|
||||
"state": state,
|
||||
"timestamps": timestamps,
|
||||
"notes": notes,
|
||||
"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:
|
||||
"""Convert an org timestamp body like '2026-02-12 Thu 16:00' to ISO 8601."""
|
||||
m = re.match(
|
||||
@ -712,10 +789,18 @@ def build_index_page(
|
||||
"</form>"
|
||||
)
|
||||
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 = (
|
||||
f"<h2>{html.escape(selected.relative_path)}</h2>"
|
||||
f"<div class='toolbar'>{mode_toggle}</div>"
|
||||
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>"
|
||||
)
|
||||
|
||||
@ -781,6 +866,10 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "")
|
||||
self.serve_static(parsed.path)
|
||||
return
|
||||
|
||||
if parsed.path == "/sw.js":
|
||||
self.serve_service_worker()
|
||||
return
|
||||
|
||||
if parsed.path != "/":
|
||||
self.send_error(404, "Not found")
|
||||
return
|
||||
@ -810,6 +899,9 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "")
|
||||
elif error == "missing":
|
||||
status_message = "Select a valid .org file first."
|
||||
status_level = "error"
|
||||
elif error == "empty":
|
||||
status_message = "Enter some text to capture."
|
||||
status_level = "error"
|
||||
elif error == "write":
|
||||
status_message = "Could not write this file."
|
||||
status_level = "error"
|
||||
@ -836,6 +928,9 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "")
|
||||
if parsed.path == "/webhook":
|
||||
self.handle_webhook()
|
||||
return
|
||||
if parsed.path == "/capture":
|
||||
self.handle_capture()
|
||||
return
|
||||
if parsed.path != "/edit":
|
||||
self.send_error(404, "Not found")
|
||||
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"})
|
||||
|
||||
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:
|
||||
if not webhook_url:
|
||||
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"})
|
||||
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_file = find_org_file(org_files, file_path)
|
||||
if org_file is None:
|
||||
self.send_json_response(404, {"error": "File not found"})
|
||||
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:
|
||||
self.send_json_response(404, {"error": "Heading not found"})
|
||||
return
|
||||
@ -896,11 +1021,24 @@ def make_handler(base_dir: Path, webhook_url: str = "", webhook_token: str = "")
|
||||
if 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:
|
||||
req = Request(webhook_url, data=encoded_data, headers=headers, method="POST")
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
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:
|
||||
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.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:
|
||||
return
|
||||
|
||||
|
||||
BIN
static/icon-192.png
Normal file
BIN
static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 693 B |
BIN
static/icon-512-maskable.png
Normal file
BIN
static/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
static/icon-512.png
Normal file
BIN
static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
29
static/manifest.webmanifest
Normal file
29
static/manifest.webmanifest
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -386,6 +386,43 @@ main h2 {
|
||||
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 {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
@ -618,9 +655,15 @@ body[data-theme="dark"] .webhook-send-btn.webhook-sent {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
main {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
nav {
|
||||
order: 2;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: none;
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@ -631,6 +674,7 @@ body[data-theme="dark"] .webhook-send-btn.webhook-sent {
|
||||
}
|
||||
|
||||
.backlinks-pane {
|
||||
order: 3;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
53
static/sw.js
Normal file
53
static/sw.js
Normal 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;
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
@ -4,6 +4,13 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
@ -316,12 +323,14 @@
|
||||
if (!btn) return;
|
||||
const file = btn.getAttribute("data-file");
|
||||
const heading = btn.getAttribute("data-heading");
|
||||
const state = btn.getAttribute("data-state") || "STRT";
|
||||
const label = btn.textContent.trim();
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Sending\u2026";
|
||||
fetch("/webhook", {
|
||||
method: "POST",
|
||||
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 (data) {
|
||||
@ -340,12 +349,19 @@
|
||||
.finally(function () {
|
||||
setTimeout(function () {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Start work";
|
||||
btn.textContent = label;
|
||||
btn.classList.remove("webhook-sent", "webhook-error");
|
||||
}, 3000);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", function () {
|
||||
navigator.serviceWorker.register("/sw.js").catch(function () {});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user