From 174d89bb4d8b117f8d5b00eb3b67103c3fdff6f4 Mon Sep 17 00:00:00 2001 From: rn404 Date: Sat, 8 Aug 2026 17:56:43 +0900 Subject: [PATCH 1/5] docs: mark Phase B done, confirm timeline line format Resolve two ambiguities in the Phase B mockup before implementing: memo lines keep their hash/tags (del/tag need it, so omitting it would just force a separate list lookup), and the "Today's logs are..." header stays for consistency with every other list mode. --- docs/memo-log-redesign.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/memo-log-redesign.md b/docs/memo-log-redesign.md index c3d5774..d328a6c 100644 --- a/docs/memo-log-redesign.md +++ b/docs/memo-log-redesign.md @@ -92,12 +92,17 @@ graph TD; ``` $ sava list -- 09:12 ・ standup メモ +- 09:12 ・ standup メモ (90ab7a28) - 09:30 [ ] fix bug (7ba24aef) #cli -- 10:02 ・ shrimp 元気 +- 10:02 ・ shrimp 元気 (3c115016) - 11:15 [x] review PR (1ed29de4) ``` +メモ行にも hash(と、あれば tag)を表示する(del/tag の操作対象になるため省かない)。 +既存のセクション表示(`Task ->`/`Memo ->`)から、発生時刻順の単一リストに変わる点が +このフォーマットの本質: 各行は `- HH:MM <マーカー> content (hash)#tags`、マーカーは +メモが `・`、TODO が `[ ]`/`[x]`/`[>]`。 + ## 自動 carry の仕様 ### トリガー: 「今日のログへの最初の書き込み」 @@ -240,7 +245,7 @@ Added!! * [x] Phase A: `add` メモ化 / `todo` コマンド新設(作成・`-s`・`-t`)/ `start`・`end`(独立コマンドのまま、複数 hash 対応で #10 を吸収)/ 追加・削除時の結果出力(#25) -* [ ] Phase B: `list` タイムライン化 +* [x] Phase B: `list` タイムライン化 * [ ] Phase C: 自動 carry(トリガー・対象日探索・新 hash コピー・ `carriedFrom`・`logfile.Update` の凍結ガード撤去と呼び出し側での 凍結チェック)→ #8 解決、#12 再評価 From c06801f869a60315b5c7a177b447aa8feb6e8560 Mon Sep 17 00:00:00 2001 From: rn404 Date: Sat, 8 Aug 2026 17:57:19 +0900 Subject: [PATCH 2/5] feat(list): render today's log as a single timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list's default view moves from Task/Memo sections to one chronological list (log.Timeline merges and sorts both, replacing the split-then-render path for this case). Each line now leads with its creation time and a marker ("・" for a memo, "[ ]"/"[x]"/"[>]" for a task), with the hash and tags trailing in parens — memos keep their hash since del/tag still need it. view.ItemList is removed; Timeline is its only remaining caller. -t tag filtering and all other list modes (-a, -s, a specific date) are unaffected. --- cmd/sava/root_test.go | 35 ++++++++++++++++++-------------- internal/command/command.go | 7 +++---- internal/command/command_test.go | 2 +- internal/log/log.go | 21 ++++++++++++++----- internal/log/log_test.go | 18 ++++++++++++++++ internal/view/view.go | 34 ++++++++++++------------------- internal/view/view_test.go | 35 +++++++++++++++++--------------- 7 files changed, 90 insertions(+), 62 deletions(-) diff --git a/cmd/sava/root_test.go b/cmd/sava/root_test.go index e7bb639..7d6d623 100644 --- a/cmd/sava/root_test.go +++ b/cmd/sava/root_test.go @@ -21,6 +21,23 @@ func execute(t *testing.T, args ...string) (string, error) { return buf.String(), err } +// openTaskHashes extracts the hash of every open (unchecked) task +// line in a `sava list` timeline, e.g. "- 09:30 [ ] fix bug (7ba24aef)". +func openTaskHashes(list string) []string { + var hashes []string + for _, line := range strings.Split(list, "\n") { + if !strings.Contains(line, "[ ]") { + continue + } + open, close := strings.Index(line, "("), strings.Index(line, ")") + if open == -1 || close == -1 || close < open { + continue + } + hashes = append(hashes, line[open+1:close]) + } + return hashes +} + func mustExecute(t *testing.T, args ...string) string { t.Helper() @@ -48,7 +65,7 @@ func TestAddListFlow(t *testing.T) { mustExecute(t, "add", "shrimp memo") out := mustExecute(t, "list") - for _, want := range []string{"Task ->", "buy cabbage", "Memo ->", "shrimp memo"} { + for _, want := range []string{"[ ] buy cabbage", "・ shrimp memo"} { if !strings.Contains(out, want) { t.Errorf("list output should contain %q:\n%s", want, out) } @@ -91,13 +108,7 @@ func TestEndMultipleHashes(t *testing.T) { mustExecute(t, "todo", "second task") list := mustExecute(t, "list") - var hashes []string - for _, line := range strings.Split(list, "\n") { - if strings.HasPrefix(line, "- [ ]") { - fields := strings.Fields(line) - hashes = append(hashes, fields[len(fields)-1]) - } - } + hashes := openTaskHashes(list) if len(hashes) != 2 { t.Fatalf("hashes = %+v, want 2:\n%s", hashes, list) } @@ -158,13 +169,7 @@ func TestDiffFlow(t *testing.T) { mustExecute(t, "todo", "second task") list := mustExecute(t, "list") - var hashes []string - for _, line := range strings.Split(list, "\n") { - if strings.HasPrefix(line, "- [ ]") { - fields := strings.Fields(line) - hashes = append(hashes, fields[len(fields)-1]) - } - } + hashes := openTaskHashes(list) if len(hashes) != 2 { t.Fatalf("hashes = %+v, want 2:\n%s", hashes, list) } diff --git a/internal/command/command.go b/internal/command/command.go index eb4b3a0..7bc4ff9 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -443,12 +443,11 @@ func listOneDay(w io.Writer, dir string, opts ListOptions) error { return nil } - tasks, memos := log.Split(file.Body) + items := log.Timeline(file.Body) if len(opts.Tags) > 0 { - tasks = log.FilterByTags(tasks, opts.Tags, opts.Or) - memos = log.FilterByTags(memos, opts.Tags, opts.Or) + items = log.FilterByTags(items, opts.Tags, opts.Or) } - view.ItemList(w, tasks, memos) + view.Timeline(w, items) return nil } diff --git a/internal/command/command_test.go b/internal/command/command_test.go index 16e4baa..7de3a11 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -553,7 +553,7 @@ func TestListToday(t *testing.T) { t.Fatal(err) } - for _, want := range []string{"Today's logs are...", "Task ->", "buy cabbage", "Memo ->", "shrimp memo"} { + for _, want := range []string{"Today's logs are...", "[ ] buy cabbage", "・ shrimp memo"} { if !strings.Contains(out.String(), want) { t.Errorf("list output should contain %q:\n%s", want, out.String()) } diff --git a/internal/log/log.go b/internal/log/log.go index 839c6a1..c9e6b72 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -252,17 +252,28 @@ func Split(l model.Log) (tasks, memos []model.Item) { } } - // createdAt is a fixed-width UTC ISO string, so lexicographic - // order equals chronological order. - byCreatedAt := func(items []model.Item) func(i, j int) bool { - return func(i, j int) bool { return items[i].CreatedAt < items[j].CreatedAt } - } sort.Slice(tasks, byCreatedAt(tasks)) sort.Slice(memos, byCreatedAt(memos)) return tasks, memos } +// Timeline returns every item (tasks and memos together) sorted by +// creation time, for a single chronological list. +func Timeline(l model.Log) []model.Item { + items := make([]model.Item, len(l.Items)) + copy(items, l.Items) + sort.Slice(items, byCreatedAt(items)) + return items +} + +// byCreatedAt orders items by creation time, oldest first. createdAt +// is a fixed-width UTC ISO string, so lexicographic order equals +// chronological order. +func byCreatedAt(items []model.Item) func(i, j int) bool { + return func(i, j int) bool { return items[i].CreatedAt < items[j].CreatedAt } +} + // CountUnfinished returns the number of open tasks. func CountUnfinished(tasks []model.Item) int { count := 0 diff --git a/internal/log/log_test.go b/internal/log/log_test.go index 3c29ae9..b917dce 100644 --- a/internal/log/log_test.go +++ b/internal/log/log_test.go @@ -295,3 +295,21 @@ func TestSplit(t *testing.T) { t.Errorf("CountUnfinished = %d, want 1", got) } } + +func TestTimeline(t *testing.T) { + items := Timeline(newTestLog()) + + if len(items) != 3 { + t.Fatalf("items = %d, want 3", len(items)) + } + // Sorted by createdAt ascending, tasks and memos interleaved: + // task-done (01:00), task-open (02:00), memo-1 (03:00). + got := []string{items[0].Hash, items[1].Hash, items[2].Hash} + want := []string{"task-done", "task-open", "memo-1"} + for i := range want { + if got[i] != want[i] { + t.Errorf("items = %+v, want order %+v", got, want) + break + } + } +} diff --git a/internal/view/view.go b/internal/view/view.go index 805f3ac..d7ad26e 100644 --- a/internal/view/view.go +++ b/internal/view/view.go @@ -17,36 +17,28 @@ func Header(w io.Writer, title string) { fmt.Fprintf(w, "\n %s\n\n", title) } -// ItemList prints tasks and memos grouped with headers. -func ItemList(w io.Writer, tasks, memos []model.Item) { - if len(tasks) == 0 && len(memos) == 0 { +// Timeline prints items (tasks and memos mixed) as a single list in +// the order given, one line per item: a marker ("・" for a memo, +// "[ ]"/"[x]"/"[>]" for a task), the creation time, the content, and +// the hash/tags for reference. +func Timeline(w io.Writer, items []model.Item) { + if len(items) == 0 { fmt.Fprintln(w, "There is no body...") return } - if len(tasks) > 0 { - fmt.Fprintln(w, "Task ->") - for _, item := range tasks { - checkbox := "[ ]" + for _, item := range items { + marker := "・" + if item.IsTask() { + marker = "[ ]" switch { case item.IsClosed(): - checkbox = "[x]" + marker = "[x]" case item.IsStarted(): - checkbox = "[>]" + marker = "[>]" } - fmt.Fprintf(w, "%s %s %s (%s) %s%s\n", bullet, checkbox, item.Content, formatTime(item.CreatedAt), item.Hash, formatTags(item.Tags)) - } - } - - if len(tasks) > 0 && len(memos) > 0 { - fmt.Fprintln(w) - } - - if len(memos) > 0 { - fmt.Fprintln(w, "Memo ->") - for _, item := range memos { - fmt.Fprintf(w, "%s %s (%s) %s%s\n", bullet, item.Content, formatTime(item.CreatedAt), item.Hash, formatTags(item.Tags)) } + fmt.Fprintf(w, "%s %s %s %s (%s)%s\n", bullet, formatTime(item.CreatedAt), marker, item.Content, item.Hash, formatTags(item.Tags)) } } diff --git a/internal/view/view_test.go b/internal/view/view_test.go index f432916..b08e5e0 100644 --- a/internal/view/view_test.go +++ b/internal/view/view_test.go @@ -8,44 +8,47 @@ import ( "github.com/rn404/nippo-cli/internal/model" ) -func TestItemList(t *testing.T) { +func TestTimeline(t *testing.T) { closed := true open := false startedAt := "2026-07-05T09:00:00.000Z" - tasks := []model.Item{ + items := []model.Item{ {Hash: "aaaa1111", Content: "buy cabbage", CreatedAt: "2026-07-05T08:43:04.971Z", Closed: &closed}, {Hash: "bbbb2222", Content: "feed the shrimp", CreatedAt: "2026-07-05T08:43:05.026Z", Closed: &open}, {Hash: "dddd4444", Content: "slice cabbage", CreatedAt: "2026-07-05T08:43:05.050Z", StartedAt: &startedAt, Closed: &open}, - } - memos := []model.Item{ {Hash: "cccc3333", Content: "shrimp looks happy today", CreatedAt: "2026-07-05T08:43:05.073Z", Tags: []string{"shrimp", "pet"}}, } var buf strings.Builder - ItemList(&buf, tasks, memos) + Timeline(&buf, items) out := buf.String() for _, want := range []string{ - "Task ->", - "- [x] buy cabbage (", - ") aaaa1111", - "- [ ] feed the shrimp (", - "- [>] slice cabbage (", - "Memo ->", - "- shrimp looks happy today (", - ") cccc3333 #shrimp #pet", + "- ", + "[x] buy cabbage (aaaa1111)", + "[ ] feed the shrimp (bbbb2222)", + "[>] slice cabbage (dddd4444)", + "・ shrimp looks happy today (cccc3333) #shrimp #pet", } { if !strings.Contains(out, want) { t.Errorf("output should contain %q:\n%s", want, out) } } + + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) != 4 { + t.Fatalf("lines = %d, want 4 (one per item):\n%s", len(lines), out) + } + if !strings.Contains(lines[3], "cccc3333") { + t.Errorf("last line should be the latest item (shrimp memo): %q", lines[3]) + } } -func TestItemListEmpty(t *testing.T) { +func TestTimelineEmpty(t *testing.T) { var buf strings.Builder - ItemList(&buf, nil, nil) + Timeline(&buf, nil) if !strings.Contains(buf.String(), "There is no body...") { - t.Errorf("empty list output = %q", buf.String()) + t.Errorf("empty timeline output = %q", buf.String()) } } From 593ec5ff9ee4c0277c515f0851b4151500212de8 Mon Sep 17 00:00:00 2001 From: rn404 Date: Sat, 8 Aug 2026 18:50:48 +0900 Subject: [PATCH 3/5] fix(log,cli): harden timeline sort and open-task hash parsing - Split/Timeline: sort.Slice -> sort.SliceStable so items sharing the same millisecond CreatedAt keep a deterministic order instead of an unspecified one. - openTaskHashes: match the "[ ]" marker only at its fixed position and read the hash from the last parenthesized group, so item content containing "[ ]" or literal parentheses no longer produces a false match. Found in Phase B code review; findings 4-6 from the same review are documented in docs/reports/2026-08-08-phase-b-code-review.md and left open for Phase C. --- cmd/sava/root_test.go | 25 +++++++++++++++++++++++-- internal/log/log.go | 10 ++++++---- internal/log/log_test.go | 21 +++++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/cmd/sava/root_test.go b/cmd/sava/root_test.go index 7d6d623..7b060d5 100644 --- a/cmd/sava/root_test.go +++ b/cmd/sava/root_test.go @@ -23,13 +23,19 @@ func execute(t *testing.T, args ...string) (string, error) { // openTaskHashes extracts the hash of every open (unchecked) task // line in a `sava list` timeline, e.g. "- 09:30 [ ] fix bug (7ba24aef)". +// It matches the "[ ]" marker only at its fixed position right after +// the bullet and timestamp (not anywhere in the line) and reads the +// hash from the LAST parenthesized group, so item content that +// happens to contain "[ ]" or literal parentheses doesn't produce a +// false match. func openTaskHashes(list string) []string { + const markerOffset = len("- 00:00 ") // bullet + space + "HH:MM" + space var hashes []string for _, line := range strings.Split(list, "\n") { - if !strings.Contains(line, "[ ]") { + if len(line) <= markerOffset || !strings.HasPrefix(line[markerOffset:], "[ ]") { continue } - open, close := strings.Index(line, "("), strings.Index(line, ")") + open, close := strings.LastIndex(line, "("), strings.LastIndex(line, ")") if open == -1 || close == -1 || close < open { continue } @@ -38,6 +44,21 @@ func openTaskHashes(list string) []string { return hashes } +// TestOpenTaskHashesIgnoresContentThatLooksLikeAMarker guards against +// two false-match bugs found in review: a memo whose content contains +// the literal substring "[ ]" must not be mistaken for an open task, +// and a task whose content contains parentheses before the trailing +// (hash) must still yield the real hash, not the content's own text. +func TestOpenTaskHashesIgnoresContentThatLooksLikeAMarker(t *testing.T) { + list := "- 09:12 ・ use [ ] for checkboxes (aaaa1111)\n" + + "- 09:30 [ ] call (urgent) client (bbbb2222)\n" + + got := openTaskHashes(list) + if len(got) != 1 || got[0] != "bbbb2222" { + t.Errorf("openTaskHashes = %+v, want exactly [bbbb2222]", got) + } +} + func mustExecute(t *testing.T, args ...string) string { t.Helper() diff --git a/internal/log/log.go b/internal/log/log.go index c9e6b72..17b3aa3 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -252,8 +252,8 @@ func Split(l model.Log) (tasks, memos []model.Item) { } } - sort.Slice(tasks, byCreatedAt(tasks)) - sort.Slice(memos, byCreatedAt(memos)) + sort.SliceStable(tasks, byCreatedAt(tasks)) + sort.SliceStable(memos, byCreatedAt(memos)) return tasks, memos } @@ -263,13 +263,15 @@ func Split(l model.Log) (tasks, memos []model.Item) { func Timeline(l model.Log) []model.Item { items := make([]model.Item, len(l.Items)) copy(items, l.Items) - sort.Slice(items, byCreatedAt(items)) + sort.SliceStable(items, byCreatedAt(items)) return items } // byCreatedAt orders items by creation time, oldest first. createdAt // is a fixed-width UTC ISO string, so lexicographic order equals -// chronological order. +// chronological order. Ties (items created within the same +// millisecond) are broken by insertion order via a stable sort, so +// output order is deterministic even when timestamps collide. func byCreatedAt(items []model.Item) func(i, j int) bool { return func(i, j int) bool { return items[i].CreatedAt < items[j].CreatedAt } } diff --git a/internal/log/log_test.go b/internal/log/log_test.go index b917dce..97ccf28 100644 --- a/internal/log/log_test.go +++ b/internal/log/log_test.go @@ -313,3 +313,24 @@ func TestTimeline(t *testing.T) { } } } + +// TestTimelineTiesKeepInsertionOrder guards against a non-deterministic +// tie-break: items sharing the exact same CreatedAt (possible within +// the same millisecond) must keep their original relative order. +func TestTimelineTiesKeepInsertionOrder(t *testing.T) { + l := model.Log{Items: []model.Item{ + {Hash: "first", CreatedAt: "2026-07-05T02:00:00.000Z"}, + {Hash: "second", CreatedAt: "2026-07-05T02:00:00.000Z"}, + {Hash: "third", CreatedAt: "2026-07-05T02:00:00.000Z"}, + }} + + items := Timeline(l) + got := []string{items[0].Hash, items[1].Hash, items[2].Hash} + want := []string{"first", "second", "third"} + for i := range want { + if got[i] != want[i] { + t.Errorf("items = %+v, want insertion order %+v", got, want) + break + } + } +} From de7774d12284b3b21e7b71e2ec3a8e10673b4bd1 Mon Sep 17 00:00:00 2001 From: rn404 Date: Sat, 8 Aug 2026 23:01:12 +0900 Subject: [PATCH 4/5] refactor(model,view): move marker precedence into model.Item.Status Add model.Item.Status(), giving Closed precedence over Started, so the memo/open/started/closed precedence lives with the data instead of being re-derived inline in view.Timeline. view.Timeline now maps Status() to a marker with a flat switch instead of an if-guarded one. Addresses findings 5-6 from the Phase B code review (already documented as deferred in docs/reports/2026-08-08-phase-b-code-review.md, now updated to reflect the fix). --- internal/model/model.go | 27 +++++++++++++++++++++++++++ internal/model/model_test.go | 25 +++++++++++++++++++++++++ internal/view/view.go | 26 +++++++++++++++----------- 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/internal/model/model.go b/internal/model/model.go index b543682..885484c 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -74,6 +74,33 @@ func (i Item) IsStarted() bool { return i.StartedAt != nil } +// Status describes an item's lifecycle stage: a memo, or a task that is +// open, started, or closed. +type Status int + +const ( + StatusMemo Status = iota + StatusOpen + StatusStarted + StatusClosed +) + +// Status reports the item's lifecycle stage, giving Closed precedence +// over Started so a task that was started and then finished still +// reports as closed rather than started. +func (i Item) Status() Status { + switch { + case !i.IsTask(): + return StatusMemo + case i.IsClosed(): + return StatusClosed + case i.IsStarted(): + return StatusStarted + default: + return StatusOpen + } +} + // HasTag reports whether the item carries the tag. func (i Item) HasTag(tag string) bool { for _, t := range i.Tags { diff --git a/internal/model/model_test.go b/internal/model/model_test.go index e36a8e8..98a6e1c 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -87,6 +87,31 @@ func TestNewItems(t *testing.T) { } } +// TestStatus pins the precedence a caller can rely on without +// re-deriving it from IsTask/IsClosed/IsStarted: Closed always wins +// over Started, even for a task that was both started and finished. +func TestStatus(t *testing.T) { + closed, open := true, false + now := NowISO() + + cases := []struct { + name string + item Item + want Status + }{ + {"memo", Item{}, StatusMemo}, + {"open task", Item{Closed: &open}, StatusOpen}, + {"started task", Item{Closed: &open, StartedAt: &now}, StatusStarted}, + {"closed task", Item{Closed: &closed}, StatusClosed}, + {"closed and started task", Item{Closed: &closed, StartedAt: &now}, StatusClosed}, + } + for _, c := range cases { + if got := c.item.Status(); got != c.want { + t.Errorf("%s: Status() = %v, want %v", c.name, got, c.want) + } + } +} + func TestNowISOFormat(t *testing.T) { pattern := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$`) if now := NowISO(); !pattern.MatchString(now) { diff --git a/internal/view/view.go b/internal/view/view.go index d7ad26e..20e8259 100644 --- a/internal/view/view.go +++ b/internal/view/view.go @@ -28,17 +28,21 @@ func Timeline(w io.Writer, items []model.Item) { } for _, item := range items { - marker := "・" - if item.IsTask() { - marker = "[ ]" - switch { - case item.IsClosed(): - marker = "[x]" - case item.IsStarted(): - marker = "[>]" - } - } - fmt.Fprintf(w, "%s %s %s %s (%s)%s\n", bullet, formatTime(item.CreatedAt), marker, item.Content, item.Hash, formatTags(item.Tags)) + fmt.Fprintf(w, "%s %s %s %s (%s)%s\n", bullet, formatTime(item.CreatedAt), marker(item.Status()), item.Content, item.Hash, formatTags(item.Tags)) + } +} + +// marker renders a lifecycle status as its display marker. +func marker(status model.Status) string { + switch status { + case model.StatusClosed: + return "[x]" + case model.StatusStarted: + return "[>]" + case model.StatusOpen: + return "[ ]" + default: + return "・" } } From ae112e5365ae8e2ed6a41f0fedacdbf168618cea Mon Sep 17 00:00:00 2001 From: rn404 Date: Sat, 8 Aug 2026 23:54:28 +0900 Subject: [PATCH 5/5] fix: satisfy golangci-lint (revive) on the guard CI check - openTaskHashes: rename open/close locals so they don't shadow the close builtin. - model.go: add a doc comment on the Status const block so exported constants aren't flagged as undocumented. --- cmd/sava/root_test.go | 6 +++--- internal/model/model.go | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/sava/root_test.go b/cmd/sava/root_test.go index 7b060d5..36c10c4 100644 --- a/cmd/sava/root_test.go +++ b/cmd/sava/root_test.go @@ -35,11 +35,11 @@ func openTaskHashes(list string) []string { if len(line) <= markerOffset || !strings.HasPrefix(line[markerOffset:], "[ ]") { continue } - open, close := strings.LastIndex(line, "("), strings.LastIndex(line, ")") - if open == -1 || close == -1 || close < open { + openParen, closeParen := strings.LastIndex(line, "("), strings.LastIndex(line, ")") + if openParen == -1 || closeParen == -1 || closeParen < openParen { continue } - hashes = append(hashes, line[open+1:close]) + hashes = append(hashes, line[openParen+1:closeParen]) } return hashes } diff --git a/internal/model/model.go b/internal/model/model.go index 885484c..ee99a1d 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -78,6 +78,7 @@ func (i Item) IsStarted() bool { // open, started, or closed. type Status int +// The Status values, in precedence order from lowest to highest. const ( StatusMemo Status = iota StatusOpen