Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 24 additions & 16 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,21 @@ r -e 'tinypkgr::check()'
## Architecture

**Core data flow:**
1. `parse_todo()` - Parse .txt file → data.frame with columns: id, parent_id, period, section, name, recur, status, level, order, path
1. `parse_todo()` - Parse .md (or legacy .txt) file → data.frame with columns: id, parent_id, period, section, name, recur, status, level, order, path
2. `inherit_recur_to_parents()` - Bubble `recur=TRUE` up to parent tasks
3. `rollup_status()` - Update parent status based on children (all x → x, any / → /, etc.)
4. `write_todo_txt()` - Write data.frame back to .txt format
4. `write_todo_txt()` - Write data.frame back to .md format

**Key modules:**
- `R/parse.R` - Text parsing to data.frame (internal/full schema)
- `R/parse.R` - Text parsing to data.frame (internal/full schema). `.parse_task_line()` is the dual-format lexer used by `parse_todo`, `roll_day`, `next_day`, and `tasks()`.
- `R/tasks.R` - Agent-facing read API (`tasks()`)
- `R/rollup.R` - Parent status calculation
- `R/advance.R` - Period advancement logic (weekly/monthly/quarterly rollover)
- `R/cli.R` - User-facing functions: `run_monday()`, `fix_parents()`, `sync_from_daily()`, `next_day()`
- `R/roll_day.R` - Day-to-day list rollover (`roll_day()`)
- `R/io.R` - File I/O (txt, markdown, html output)
- `R/recurring.R` - Manifest reader + materializer
- `R/migrate.R` - One-shot legacy `.txt` → `.md` converter (`migrate_to_markdown()`)
- `R/io.R` - File I/O (markdown writer, html mirror)
- `R/config.R` - Configuration via `config.yaml` or `hacer_config.R`

## Agent-facing read API
Expand All @@ -53,23 +55,29 @@ r -e 'tinypkgr::check()'

## Preview mode

Every mutator (`roll_day`, `run_monday`, `fix_parents`, `next_day`, `sync_from_daily`, `instantiate_todo`) accepts `preview = TRUE` and returns a `hacer_preview` describing the would-be change without writing. Set `HACER_PREVIEW=1` to flip the default — useful for one-shot agent invocations that should be inspectable before they touch the user's todo repo. Internals live in `R/preview.R`; each mutator builds a `targets` list of `path -> new_lines` and dispatches via `.write_or_preview()`.
Every mutator (`roll_day`, `run_monday`, `fix_parents`, `next_day`, `sync_from_daily`, `instantiate_todo`, `migrate_to_markdown`) accepts `preview = TRUE` and returns a `hacer_preview` describing the would-be change without writing. Set `HACER_PREVIEW=1` to flip the default — useful for one-shot agent invocations that should be inspectable before they touch the user's todo repo. Internals live in `R/preview.R`; each mutator builds a `targets` list of `path -> new_lines` and dispatches via `.write_or_preview()`.

## Task File Format
## Task File Format (0.2.0+)

```
# Section Header
```markdown
# todo_yymmdd_daily.md

## Monday

[ ] - Parent Task
[/] - Child in progress
[x] - Child done
[ ] -*Recurring Task
- [ ] Parent Task
- [/] Child in progress
- [x] Child done
- [!] Blocked thing
```

- Two spaces per indent level
- Status: `[ ]` todo, `[/]` in progress, `[x]` done, `[!]` blocked
- `*` prefix = recurring (preserved across rollovers)
- `[!]` is sticky: rollup gives it precedence over all other statuses, and `roll_day()` / `run_monday()` / `next_day()` preserve it verbatim until a human or agent changes it
- Standard markdown task list. Two spaces per indent level.
- Status: `[ ]` todo, `[/]` in progress, `[x]` done, `[!]` blocked. `[/]` and `[!]` are non-standard but render fine in Obsidian.
- Day sections in Daily are `## ` (H2). The first line is the H1 file-name header.
- Recurring tasks are declared in `recurring.txt`, not as inline `*` markers.

The dual-format parser still reads pre-0.2.0 files (`[X] - text` syntax in `.txt` files with `# Section` H1 day headers). The writer never emits the legacy format. `migrate_to_markdown()` does a one-shot conversion of the live dir.

- `[!]` is sticky: rollup gives it precedence over all other statuses, and `roll_day()` / `run_monday()` / `next_day()` preserve it verbatim until a human or agent changes it.

## Dependencies

Expand Down
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Package: hacer
Title: Plain-Text Nested ToDo Planning
Version: 0.1.8
Version: 0.2.0
Authors@R: c(
person("Troy", "Hernandez", role = c("aut", "cre"),
email = "troy@cornball.ai",
Expand Down
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export(fix_parents)
export(infer_period_from_filename)
export(inherit_recur_to_parents)
export(instantiate_todo)
export(migrate_to_markdown)
export(next_day)
export(open_this_week)
export(parse_todo)
Expand Down
30 changes: 12 additions & 18 deletions R/cli.R
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,10 @@ run_monday <- function(date = Sys.Date(), cfg = todo_config(),
targets <- list()
for (p in .period_types) {
df <- nxt[[p]]
txt_path <- dst$live[.period_types == p]
targets[[txt_path]] <- build_todo_txt_lines(df, txt_path, p, cfg)
if (isTRUE(cfg$render_markdown)) {
md_path <- sub("\\.txt$", ".md", txt_path)
targets[[md_path]] <- build_markdown_lines(df, md_path, p, cfg)
}
md_path <- dst$live[.period_types == p]
targets[[md_path]] <- build_todo_txt_lines(df, md_path, p, cfg)
if (isTRUE(cfg$render_html)) {
html_path <- sub("\\.txt$", ".html", txt_path)
html_path <- sub("\\.md$", ".html", md_path)
targets[[html_path]] <- build_simple_html_lines(df, html_path, p)
}
}
Expand Down Expand Up @@ -248,13 +244,13 @@ next_day <- function(date = Sys.Date(), cfg = todo_config(),
last_was_task <- FALSE
next
}
if (!grepl("^\\s*\\[", ln)) next
parsed <- .parse_task_line(ln)
if (is.null(parsed)) next

status <- substr(sub("^\\s*", "", ln), 2, 2)
if (status == "x") next
if (parsed$status == "x") next

is_daily_recur <- grepl("-\\s*\\*?(Email|ToDo)\\s*$", ln, ignore.case = TRUE)
if (is_daily_recur && status == "/") next
is_daily_recur <- grepl("^(Email|ToDo)$", parsed$name, ignore.case = TRUE)
if (is_daily_recur && parsed$status == "/") next

ln_reset <- sub("\\[/\\]", "[ ]", ln)
ln_reset <- sub("\\[x\\]", "[ ]", ln_reset)
Expand All @@ -264,13 +260,11 @@ next_day <- function(date = Sys.Date(), cfg = todo_config(),

keep_today <- character()
for (ln in today_lines) {
if (!grepl("^\\s*\\[", ln)) {
parsed <- .parse_task_line(ln)
if (is.null(parsed)) {
keep_today <- c(keep_today, ln)
} else if (parsed$status %in% c("/", "x", "!")) {
keep_today <- c(keep_today, ln)
} else {
status <- substr(sub("^\\s*", "", ln), 2, 2)
if (status %in% c("/", "x", "!")) {
keep_today <- c(keep_today, ln)
}
}
}

Expand Down
3 changes: 1 addition & 2 deletions R/config.R
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,8 @@ todo_config <- function(repo_dir = .default_repo_dir()) {
indent = 2L,
live_dir = file.path(repo_dir, "this_week"),
archive_dir = file.path(repo_dir, "archive"),
filename_fmt = "todo_%y%m%d_%s.txt",
filename_fmt = "todo_%y%m%d_%s.md",
daily_sections = c("Monday","Tuesday","Wednesday","Thursday","Friday"),
render_markdown = TRUE,
render_html = TRUE
)
}
Expand Down
3 changes: 1 addition & 2 deletions R/instantiate.R
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,8 @@ instantiate_todo <- function(repo_dir,
" indent = 2L,",
paste0(" live_dir = '", live_dir, "',"),
paste0(" archive_dir = '", archive_dir, "',"),
" filename_fmt = 'todo_%y%m%d_%s.txt',",
" filename_fmt = 'todo_%y%m%d_%s.md',",
" daily_sections = c('Monday','Tuesday','Wednesday','Thursday','Friday'),",
" render_markdown = TRUE,",
" render_html = TRUE",
")"
)
Expand Down
45 changes: 3 additions & 42 deletions R/io.R
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ build_todo_txt_lines <- function(df, file, period, cfg = todo_config()) {
secs <- unique(df$section)
secs <- secs[!is.na(secs)]
for (s in secs) {
lines <- c(lines, "", "#######################################", paste0("\n# ", s), "")
lines <- c(lines, "", paste0("## ", s), "")
part <- df[df$section == s, , drop=FALSE]
lines <- c(lines, .df_to_lines(part, cfg$indent))
}
} else {
lines <- c(lines, "", "#######################################", "")
lines <- c(lines, "")
lines <- c(lines, .df_to_lines(df, cfg$indent))
}
lines
Expand All @@ -29,50 +29,11 @@ write_todo_txt <- function(df, file, period, cfg = todo_config()) {
for (i in seq_len(nrow(df))) {
pad <- paste(rep(" ", df$level[i] * indent), collapse = "")
stat <- paste0("[", df$status[i], "]")
if (isTRUE(df$recur[i])) {
out[i] <- paste0(pad, stat, " -*", df$name[i])
} else {
out[i] <- paste0(pad, stat, " - ", df$name[i])
}
out[i] <- paste0(pad, "- ", stat, " ", df$name[i])
}
out
}

# optional mirrors:
build_markdown_lines <- function(df, file_md, period, cfg = todo_config()) {
lines <- character()
lines <- c(lines, paste0("# ", sub("\\.md$", "", basename(file_md))))

one <- function(df) {
if (!nrow(df)) return(character())
df <- df[order(df$order), , drop=FALSE]
out <- character(nrow(df))
for (i in seq_len(nrow(df))) {
pad <- paste(rep(" ", df$level[i]), collapse = "")
ck <- if (df$status[i] == "x") "x" else if (df$status[i] == "/") "-" else " "
nm <- if (df$recur[i]) paste0("*", df$name[i]) else df$name[i]
out[i] <- paste0(pad, "- [", ck, "] ", nm)
}
out
}

if (period == "Daily") {
secs <- unique(df$section); secs <- secs[!is.na(secs)]
for (s in secs) {
lines <- c(lines, "", paste0("## ", s), "")
lines <- c(lines, one(df[df$section == s, , drop=FALSE]))
}
} else {
lines <- c(lines, "", "## Tasks", "")
lines <- c(lines, one(df))
}
lines
}

write_markdown <- function(df, file_md, period, cfg = todo_config()) {
writeLines(build_markdown_lines(df, file_md, period, cfg), file_md)
}

build_simple_html_lines <- function(df, file_html, period) {
esc <- function(x) { x <- gsub("&","&amp;",x, fixed=TRUE); x <- gsub("<","&lt;",x,fixed=TRUE); gsub(">","&gt;",x,fixed=TRUE) }
lines <- c("<!doctype html>","<meta charset='utf-8'>",
Expand Down
86 changes: 86 additions & 0 deletions R/migrate.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# R/migrate.R

#' Migrate legacy .txt todo files to markdown .md
#'
#' Walks `cfg$live_dir` for `.txt` files matching the `todo_*` pattern,
#' reads each via the dual-format parser, writes a `.md` companion in the
#' new markdown task-list syntax (`- [X] text`), and removes the `.txt`
#' source after a successful write.
#'
#' Surfaces a warning listing any tasks that carried the legacy `*`
#' recurring marker — the new writer drops `*` entirely, so add those
#' paths to `recurring.txt` if you want them to keep recurring.
#'
#' Archive files are not touched. If you want them migrated, do it
#' manually with `git mv` + a script — or wait until the question matters
#' enough to dial the defaults in.
#'
#' @param cfg Config from [todo_config()].
#' @param preview If `TRUE`, return a `hacer_preview` without writing.
#' Defaults to the `HACER_PREVIEW=1` env var or `FALSE`.
#'
#' @export
migrate_to_markdown <- function(cfg = todo_config(),
preview = .preview_default()) {
txts <- list.files(cfg$live_dir,
pattern = "^todo_\\d{6}_.+\\.txt$",
full.names = TRUE,
ignore.case = TRUE)

if (!length(txts)) {
message("No legacy .txt files in ", cfg$live_dir, ". Nothing to migrate.")
if (preview) return(.new_preview())
return(invisible(character()))
}

targets <- list()
removed <- character()
recur_tasks <- list()

for (txt in txts) {
md_path <- sub("\\.txt$", ".md", txt)
period <- infer_period_from_filename(txt)
period_eff <- if (is.na(period)) "Daily" else period
df <- parse_todo(txt, period_eff, indent = cfg$indent %||% 2L)

if (any(isTRUE_vec(df$recur))) {
recur_paths <- unique(df$path[isTRUE_vec(df$recur)])
recur_tasks[[basename(txt)]] <- recur_paths
}

new_lines <- build_todo_txt_lines(df, md_path, period_eff, cfg)
targets[[md_path]] <- new_lines
removed <- c(removed, txt)
}

result <- .write_or_preview(targets, preview)

if (preview) {
if (length(removed)) {
message("Migrate preview: would also remove ", length(removed),
" legacy .txt file(s):")
for (r in removed) message(" - ", r)
}
} else {
file.remove(removed)
message("Migrated ", length(targets), " file(s) to markdown.")
}

if (length(recur_tasks)) {
message("")
message("Tasks with legacy `*` recurring marker (the marker is no",
" longer written;\nadd these paths to recurring.txt if",
" you want them to keep recurring):")
for (file in names(recur_tasks)) {
message(" ", file, ":")
for (path in recur_tasks[[file]]) {
message(" ", path)
}
}
}

if (preview) return(result)
invisible(names(targets))
}

`%||%` <- function(a, b) if (is.null(a)) b else a
Loading
Loading