feat(tasks): use template file in tasks_dir
This commit is contained in:
parent
39a9f95432
commit
0aa39b1c37
5 changed files with 117 additions and 47 deletions
14
README.md
14
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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---
|
||||
<!-- Status = which folder this file is in (backlog/next/in-progress/done); there is no
|
||||
status field. The FILENAME's date is the last-updated date — rename it (YYYY-MM-DD-slug)
|
||||
whenever the task changes, e.g. to the completion date when it moves to done/. "Waiting"
|
||||
is a context (context: @waiting), not a folder. Link a task to a project via the
|
||||
`project:` field above. -->
|
||||
|
||||
## 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.
|
||||
2
ayo.sh
2
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 "$@"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
119
tasks
119
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)
|
||||
|
|
@ -22,6 +28,8 @@ USAGE
|
|||
tasks help
|
||||
|
||||
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
|
||||
---
|
||||
<!-- Status = which folder this file is in (backlog/next/in-progress/done); there is no
|
||||
status field. The FILENAME's date is the last-updated date — rename it (YYYY-MM-DD-slug)
|
||||
whenever the task changes. "Waiting" is a context (context: @waiting), not a folder. -->
|
||||
|
||||
## 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 <tasks_dir>/_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 `## <name>` 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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue