From 0aa39b1c37537a9c7392a69b7d85c14939fc40f4 Mon Sep 17 00:00:00 2001 From: Ayo Date: Sat, 11 Jul 2026 14:34:13 +0200 Subject: [PATCH] feat(tasks): use template file in tasks_dir --- README.md | 14 +++++- TASK_TEMPLATE.md | 26 ---------- ayo.sh | 2 +- example.conf | 1 + tasks | 121 ++++++++++++++++++++++++++++++++++++++++------- 5 files changed, 117 insertions(+), 47 deletions(-) delete mode 100644 TASK_TEMPLATE.md diff --git a/README.md b/README.md index be8e78a..222472b 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,19 @@ alias yo='~/Projects/scripts/ayo.sh' ## Tasks -The `tasks` app uses a `tasks_dir` variable in the configuration file. It looks for plain text files that follows the [template](./TASK_TEMPLATE.md). +The `tasks` app uses a `tasks_dir` variable in the configuration file. It looks for plain text files that follow the task template. -The tasks text files need to be in one of the status directories: `backlog`, `next`, `in-progress`, or `done`. +The template is a single source of truth that defines a task's shape. Its location is set by the `tasks_template` config variable, which is derived from `tasks_dir` (default `${tasks_dir}/_TEMPLATE.md`). `tasks` renders every new task from it (auto-creating it with a sensible default the first time it is missing); edit that file to change what new tasks look like. Its frontmatter fields are: + +- `id` — stable creation-stamped handle, never changes on later renames +- `title` — one-line task title +- `priority` — `P0` urgent · `P1` high · `P2` normal · `P3` someday +- `context` — `@code` · `@calls` · `@errand` · `@home` · `@admin` · `@waiting` +- `project` — optional ID(s) of related project(s) +- `created` — creation date +- `links` — related tasks or output files + +The tasks text files need to be in one of the status directories: `backlog`, `next`, `in-progress`, or `done`. Status is the folder a task lives in, and the filename's date is its last-updated date. After setting the `tasks_dir` variable in the configuration file `ayo.conf` and an alias to `ayo.sh` with `yo` in your `.bashrc` file, you can run the app with `yo tasks`. diff --git a/TASK_TEMPLATE.md b/TASK_TEMPLATE.md deleted file mode 100644 index 7158ca0..0000000 --- a/TASK_TEMPLATE.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -id: YYYY-MM-DD-short-slug # stable handle (creation-stamped); unchanged by later renames -title: One-line task title -priority: P2 # P0 urgent · P1 high · P2 normal · P3 someday -context: @admin # @code | @calls | @errand | @home | @admin | @waiting -project: # optional — ID(s) of related project(s), e.g. garden-planner or [a, b] -created: YYYY-MM-DD -links: [] # related tasks, policy docs, or output files ---- - - -## Goal -What done looks like, in one or two sentences. - -## Next action -The single concrete next step — a physical verb. - -## Notes -Context, sub-steps, blockers. - -## Log -- YYYY-MM-DD — created. diff --git a/ayo.sh b/ayo.sh index 9673d6f..87f6316 100755 --- a/ayo.sh +++ b/ayo.sh @@ -81,7 +81,7 @@ case $1 in . ${scripts_dir}/notes.sh $@ ;; t | tasks) - TASKS_HOME="${tasks_dir}" ${scripts_dir}/tasks "${@:2}" + TASKS_HOME="${tasks_dir}" TASKS_TEMPLATE="${tasks_template}" ${scripts_dir}/tasks "${@:2}" ;; j | journal) . ${scripts_dir}/journal.sh "$@" diff --git a/example.conf b/example.conf index 68fcd62..3e492d0 100644 --- a/example.conf +++ b/example.conf @@ -1,5 +1,6 @@ notes_dir="${HOME}/Notes" tasks_dir="${HOME}/Notes/Tasks" +tasks_template="${tasks_dir}/_TEMPLATE.md" scripts_dir="${HOME}/Projects/scripts" editor="vim" skip_flatpak=true diff --git a/tasks b/tasks index b9d7023..1834b91 100755 --- a/tasks +++ b/tasks @@ -10,6 +10,12 @@ THE STORE drift — it re-dates filenames on every mutation, stamps the ## Log, uses `git mv` (in whichever repo the file lives), and never touches `id:`. + New tasks are rendered from a single template file — the one source of truth for + a task's shape. Its location is $TASKS_TEMPLATE (from your config, derived from + `tasks_dir`), defaulting to $TASKS_HOME/_TEMPLATE.md. Edit it to change what new + tasks look like; it is auto-created with a sensible default the first time it is + missing. + USAGE tasks Launch the TUI tasks list [--status S] Print a table (headless) @@ -21,7 +27,9 @@ USAGE tasks selftest Exercise the core logic on a temp store; print OK tasks help - Env: TASKS_HOME = your tasks directory (from `tasks_dir` in your config) + Env: TASKS_HOME = your tasks directory (from `tasks_dir` in your config) + TASKS_TEMPLATE = the template file (from `tasks_template`, default + $TASKS_HOME/_TEMPLATE.md) """ import curses @@ -48,6 +56,38 @@ FIELD_ORDER = [ DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}-") +# The task template is a single source of truth: one file in the tasks root that +# both `new`/`c` render from and that you edit to change what a new task looks +# like. DEFAULT_TEMPLATE only seeds it the first time (e.g. a fresh store). +TEMPLATE_NAME = "_TEMPLATE.md" + +DEFAULT_TEMPLATE = """\ +--- +id: YYYY-MM-DD-short-slug # stable handle (creation-stamped); unchanged by later renames +title: One-line task title +priority: P2 # P0 urgent · P1 high · P2 normal · P3 someday +context: @admin # @code | @calls | @errand | @home | @admin | @waiting +project: # optional — ID(s) of related project(s), e.g. garden-planner or [a, b] +created: YYYY-MM-DD +links: [] # related tasks or output files +--- + + +## Goal +What done looks like, in one or two sentences. + +## Next action +The single concrete next step — a physical verb. + +## Notes +Context, sub-steps, blockers. + +## Log +- YYYY-MM-DD — created. +""" + def tasks_dir(): root = os.environ.get("TASKS_HOME") @@ -57,6 +97,23 @@ def tasks_dir(): return root +def template_path(): + # Location comes from the config (TASKS_TEMPLATE, derived from tasks_dir); + # falls back to /_TEMPLATE.md when run without the launcher. + return os.environ.get("TASKS_TEMPLATE") or os.path.join(tasks_dir(), TEMPLATE_NAME) + + +def read_template(): + """Return the template text, seeding the store with the default if absent.""" + path = template_path() + if not os.path.isfile(path): + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + fh.write(DEFAULT_TEMPLATE) + with open(path, "r", encoding="utf-8") as fh: + return fh.read() + + def today(): return datetime.date.today().isoformat() @@ -98,6 +155,12 @@ class Task: text = fh.read() except OSError: return + self.fields, self.body = self._split(text) + + @staticmethod + def _split(text): + """Split raw markdown into (frontmatter fields, body).""" + fields, body = {}, text if text.startswith("---"): parts = text.split("\n") end = None @@ -109,10 +172,9 @@ class Task: for line in parts[1:end]: m = re.match(r"^([A-Za-z0-9_-]+):\s?(.*)$", line) if m: - self.fields[m.group(1)] = m.group(2).strip() - self.body = "\n".join(parts[end + 1:]).lstrip("\n") - return - self.body = text + fields[m.group(1)] = m.group(2).strip() + body = "\n".join(parts[end + 1:]).lstrip("\n") + return fields, body # --- convenience accessors ------------------------------------------- def get(self, key, default=""): @@ -253,28 +315,51 @@ def find_one(ref): return None -def create_task(title, priority="P2", context="@admin", - project="", status="backlog", goal="", next_action="", - notes="", links=""): +def _set_section(body, name, text): + """Replace the text under a `## ` heading; leave the rest untouched.""" + pat = re.compile(rf"(?ms)^(##\s+{re.escape(name)}\s*\n).*?(?=^##\s|\Z)") + repl = lambda m: m.group(1) + text.rstrip("\n") + "\n\n" + new, n = pat.subn(repl, body) + return new if n else body + + +def render_template(title, tid): + """Load the store's template and fill in the dynamic bits for a new task. + + The template stays self-documenting: inline `# ...` field comments and any + HTML comments are stripped here so the created task file is clean. + """ + fields, body = Task._split(read_template()) + fields = {k: re.sub(r"(?:^|\s+)#.*$", "", v).strip() for k, v in fields.items()} + body = re.sub(r"(?s)\n?", "", body).lstrip("\n") + fields["id"] = tid + fields["title"] = title + fields["created"] = today() + body = body.replace("YYYY-MM-DD — created", f"{today()} — created") + return fields, body + + +def create_task(title, priority=None, context=None, project=None, links=None, + status="backlog", goal=None, next_action=None, notes=None): ensure_dirs() if status not in STATUSES: raise ValueError(f"unknown status: {status}") slug = slugify(title) tid = f"{today()}-{slug}" + fields, body = render_template(title, tid) + for key, val in (("priority", priority), ("context", context), + ("project", project), ("links", links)): + if val is not None: + fields[key] = val + for name, text in (("Goal", goal), ("Next action", next_action), ("Notes", notes)): + if text: + body = _set_section(body, name, text) path = _unique_path(os.path.join(tasks_dir(), status, f"{today()}-{slug}.md")) t = Task.__new__(Task) t.path = path t.status = status - t.fields = { - "id": tid, "title": title, "priority": priority, "context": context, - "project": project, "created": today(), "links": links or "[]", - } - t.body = ( - "## Goal\n" + (goal or "What done looks like, in one or two sentences.") + "\n\n" - "## Next action\n" + (next_action or "The single concrete next step — a physical verb.") + "\n\n" - "## Notes\n" + (notes or "Context, sub-steps, blockers.") + "\n\n" - "## Log\n" + f"- {today()} — created.\n" - ) + t.fields = fields + t.body = body t.save() return t