diff --git a/main.py b/main.py index 24b8362..af83411 100644 --- a/main.py +++ b/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"
{tag_spans}
") - 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"" + f"data-file='{safe_file}' data-heading='{safe_heading}' " + f"data-state='STRT'>Start work " + f"" ) 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 ' 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( "" ) else: + capture_form = ( + "
" + f"" + "" + "" + "
" + ) body = ( f"

{html.escape(selected.relative_path)}

" f"
{mode_toggle}
" f"{status_html}" + f"{capture_form}" f"
{render_org_to_html(selected.content, selected.relative_path, known_paths, id_to_path, show_webhook=show_webhook)}
" ) @@ -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 diff --git a/static/icon-192.png b/static/icon-192.png new file mode 100644 index 0000000..368a672 Binary files /dev/null and b/static/icon-192.png differ diff --git a/static/icon-512-maskable.png b/static/icon-512-maskable.png new file mode 100644 index 0000000..19b9be0 Binary files /dev/null and b/static/icon-512-maskable.png differ diff --git a/static/icon-512.png b/static/icon-512.png new file mode 100644 index 0000000..cb45f01 Binary files /dev/null and b/static/icon-512.png differ diff --git a/static/manifest.webmanifest b/static/manifest.webmanifest new file mode 100644 index 0000000..1e90de1 --- /dev/null +++ b/static/manifest.webmanifest @@ -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" + } + ] +} diff --git a/static/style.css b/static/style.css index c04b209..b96f58b 100644 --- a/static/style.css +++ b/static/style.css @@ -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); } diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..afe4e71 --- /dev/null +++ b/static/sw.js @@ -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; + }) + ) + ); +}); diff --git a/templates/index.html b/templates/index.html index 5195a13..128031e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4,6 +4,13 @@ Org Web Adapter + + + + + + + @@ -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); }); }); })(); +