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 = (
+ ""
+ )
body = (
f"