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
|
## 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`.
|
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 $@
|
. ${scripts_dir}/notes.sh $@
|
||||||
;;
|
;;
|
||||||
t | tasks)
|
t | tasks)
|
||||||
TASKS_HOME="${tasks_dir}" ${scripts_dir}/tasks "${@:2}"
|
TASKS_HOME="${tasks_dir}" TASKS_TEMPLATE="${tasks_template}" ${scripts_dir}/tasks "${@:2}"
|
||||||
;;
|
;;
|
||||||
j | journal)
|
j | journal)
|
||||||
. ${scripts_dir}/journal.sh "$@"
|
. ${scripts_dir}/journal.sh "$@"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
notes_dir="${HOME}/Notes"
|
notes_dir="${HOME}/Notes"
|
||||||
tasks_dir="${HOME}/Notes/Tasks"
|
tasks_dir="${HOME}/Notes/Tasks"
|
||||||
|
tasks_template="${tasks_dir}/_TEMPLATE.md"
|
||||||
scripts_dir="${HOME}/Projects/scripts"
|
scripts_dir="${HOME}/Projects/scripts"
|
||||||
editor="vim"
|
editor="vim"
|
||||||
skip_flatpak=true
|
skip_flatpak=true
|
||||||
|
|
|
||||||
121
tasks
121
tasks
|
|
@ -10,6 +10,12 @@ THE STORE
|
||||||
drift — it re-dates filenames on every mutation, stamps the ## Log, uses `git mv`
|
drift — it re-dates filenames on every mutation, stamps the ## Log, uses `git mv`
|
||||||
(in whichever repo the file lives), and never touches `id:`.
|
(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
|
USAGE
|
||||||
tasks Launch the TUI
|
tasks Launch the TUI
|
||||||
tasks list [--status S] Print a table (headless)
|
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 selftest Exercise the core logic on a temp store; print OK
|
||||||
tasks help
|
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
|
import curses
|
||||||
|
|
@ -48,6 +56,38 @@ FIELD_ORDER = [
|
||||||
|
|
||||||
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}-")
|
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():
|
def tasks_dir():
|
||||||
root = os.environ.get("TASKS_HOME")
|
root = os.environ.get("TASKS_HOME")
|
||||||
|
|
@ -57,6 +97,23 @@ def tasks_dir():
|
||||||
return root
|
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():
|
def today():
|
||||||
return datetime.date.today().isoformat()
|
return datetime.date.today().isoformat()
|
||||||
|
|
||||||
|
|
@ -98,6 +155,12 @@ class Task:
|
||||||
text = fh.read()
|
text = fh.read()
|
||||||
except OSError:
|
except OSError:
|
||||||
return
|
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("---"):
|
if text.startswith("---"):
|
||||||
parts = text.split("\n")
|
parts = text.split("\n")
|
||||||
end = None
|
end = None
|
||||||
|
|
@ -109,10 +172,9 @@ class Task:
|
||||||
for line in parts[1:end]:
|
for line in parts[1:end]:
|
||||||
m = re.match(r"^([A-Za-z0-9_-]+):\s?(.*)$", line)
|
m = re.match(r"^([A-Za-z0-9_-]+):\s?(.*)$", line)
|
||||||
if m:
|
if m:
|
||||||
self.fields[m.group(1)] = m.group(2).strip()
|
fields[m.group(1)] = m.group(2).strip()
|
||||||
self.body = "\n".join(parts[end + 1:]).lstrip("\n")
|
body = "\n".join(parts[end + 1:]).lstrip("\n")
|
||||||
return
|
return fields, body
|
||||||
self.body = text
|
|
||||||
|
|
||||||
# --- convenience accessors -------------------------------------------
|
# --- convenience accessors -------------------------------------------
|
||||||
def get(self, key, default=""):
|
def get(self, key, default=""):
|
||||||
|
|
@ -253,28 +315,51 @@ def find_one(ref):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def create_task(title, priority="P2", context="@admin",
|
def _set_section(body, name, text):
|
||||||
project="", status="backlog", goal="", next_action="",
|
"""Replace the text under a `## <name>` heading; leave the rest untouched."""
|
||||||
notes="", links=""):
|
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()
|
ensure_dirs()
|
||||||
if status not in STATUSES:
|
if status not in STATUSES:
|
||||||
raise ValueError(f"unknown status: {status}")
|
raise ValueError(f"unknown status: {status}")
|
||||||
slug = slugify(title)
|
slug = slugify(title)
|
||||||
tid = f"{today()}-{slug}"
|
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"))
|
path = _unique_path(os.path.join(tasks_dir(), status, f"{today()}-{slug}.md"))
|
||||||
t = Task.__new__(Task)
|
t = Task.__new__(Task)
|
||||||
t.path = path
|
t.path = path
|
||||||
t.status = status
|
t.status = status
|
||||||
t.fields = {
|
t.fields = fields
|
||||||
"id": tid, "title": title, "priority": priority, "context": context,
|
t.body = body
|
||||||
"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.save()
|
t.save()
|
||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue