diff --git a/README.md b/README.md index 6679b94..0731ad9 100644 --- a/README.md +++ b/README.md @@ -40,18 +40,38 @@ go install github.com/rn404/nippo-cli/cmd/sava@latest # Add todo item sava add +# Add todo item and start it right away +sava add -s + # Add memo item sava add -m +# Start todo item +sava start + # Finish todo item sava end # Delete item sava del +# Add item with tags / manage tags afterwards +sava add -t [,...] +sava tag ... +sava tag -d ... +sava tag --list + +# Show elapsed time between two items (resolved across days) +sava diff ... +sava diff + # List today's log items sava list +# Filter by tags (multiple tags match all; --or matches any) +sava list -t [,...] +sava list -t , --or + # List items of a specific day / all log files / summaries sava list sava list -a @@ -66,12 +86,16 @@ sava clear -a ``` ログは `~/.log/sava/.json` に 1 日 1 ファイルで保存されます. +フォーマットの仕様サンプルは `testdata/log-format/` にあります. + +タグ操作時には `~/.log/sava/index.json` (タグ・hash から日付ファイルへの逆引きキャッシュ) +が再生成されます. 壊れても全ログから再構築できるキャッシュです. ### Objects * LogFile > Log > Item (Task, Memo) ### Architecture -* cmd/sava -- internal/command -- internal/{logfile, log, view} -- internal/model +* cmd/sava -- internal/command -- internal/{logfile, log, view, index} -- internal/model ## Development diff --git a/cmd/sava/commands.go b/cmd/sava/commands.go index ed95319..1c4d7e0 100644 --- a/cmd/sava/commands.go +++ b/cmd/sava/commands.go @@ -1,6 +1,9 @@ package main import ( + "fmt" + "strings" + "github.com/spf13/cobra" "github.com/rn404/nippo-cli/internal/command" @@ -8,19 +11,57 @@ import ( ) func newAddCommand() *cobra.Command { - var memo bool + opts := command.AddOptions{} cmd := &cobra.Command{ Use: "add ", Short: "Add contents to nippo log.", Args: cobra.ExactArgs(1), RunE: func(_ *cobra.Command, args []string) error { - return command.Add(logfile.Dir(), args[0], memo) + return command.Add(logfile.Dir(), args[0], opts) + }, + } + cmd.Flags().BoolVarP(&opts.Memo, "memo", "m", false, "Add contents like memo item.") + cmd.Flags().BoolVarP(&opts.Start, "start", "s", false, "start the task right away") + cmd.Flags().StringSliceVarP(&opts.Tags, "tag", "t", nil, "put tags on the new item") + cmd.MarkFlagsMutuallyExclusive("memo", "start") + return cmd +} + +func newTagCommand() *cobra.Command { + var remove, list bool + cmd := &cobra.Command{ + Use: "tag ...", + Short: "manage tags of an item.", + RunE: func(cmd *cobra.Command, args []string) error { + if list { + if len(args) != 0 { + return fmt.Errorf("--list takes no arguments") + } + return command.TagList(cmd.OutOrStdout(), logfile.Dir()) + } + if len(args) < 2 { + return fmt.Errorf("requires and at least one ") + } + return command.Tag(cmd.OutOrStdout(), logfile.Dir(), args[0], args[1:], remove) }, } - cmd.Flags().BoolVarP(&memo, "memo", "m", false, "Add contents like memo item.") + cmd.Flags().BoolVarP(&remove, "delete", "d", false, "remove the tags instead of adding") + cmd.Flags().BoolVarP(&list, "list", "l", false, "list all known tags") + cmd.MarkFlagsMutuallyExclusive("delete", "list") return cmd } +func newStartCommand() *cobra.Command { + return &cobra.Command{ + Use: "start ", + Short: "start to task.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return command.Start(cmd.OutOrStdout(), logfile.Dir(), args[0]) + }, + } +} + func newEndCommand() *cobra.Command { return &cobra.Command{ Use: "end ", @@ -43,6 +84,36 @@ func newDelCommand() *cobra.Command { } } +func newDiffCommand() *cobra.Command { + return &cobra.Command{ + Use: "diff ...", + Short: "show elapsed time between two items.", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + hashA, hashB, err := splitDiffArgs(args) + if err != nil { + return err + } + return command.Diff(cmd.OutOrStdout(), logfile.Dir(), hashA, hashB) + }, + } +} + +// splitDiffArgs accepts either "..." (also "..") as one +// argument or two separate hash arguments. +func splitDiffArgs(args []string) (string, string, error) { + if len(args) == 2 { + return args[0], args[1], nil + } + for _, sep := range []string{"...", ".."} { + parts := strings.SplitN(args[0], sep, 2) + if len(parts) == 2 && parts[0] != "" && parts[1] != "" { + return parts[0], parts[1], nil + } + } + return "", "", fmt.Errorf("expected ... or two hashes") +} + func newListCommand() *cobra.Command { opts := command.ListOptions{} cmd := &cobra.Command{ @@ -59,6 +130,8 @@ func newListCommand() *cobra.Command { cmd.Flags().BoolVarP(&opts.All, "all", "a", false, "show all logs") cmd.Flags().BoolVarP(&opts.Stat, "stat", "s", false, "show summary of list") cmd.Flags().BoolVarP(&opts.Yes, "yes", "y", false, "skip confirmation prompts") + cmd.Flags().StringSliceVarP(&opts.Tags, "tag", "t", nil, "show only items carrying the tags") + cmd.Flags().BoolVar(&opts.Or, "or", false, "match any tag instead of all") return cmd } diff --git a/cmd/sava/main.go b/cmd/sava/main.go index 3bbb2f3..f9ba286 100644 --- a/cmd/sava/main.go +++ b/cmd/sava/main.go @@ -40,8 +40,11 @@ func newRootCommand() *cobra.Command { root.AddCommand( newAddCommand(), + newStartCommand(), newEndCommand(), newDelCommand(), + newTagCommand(), + newDiffCommand(), newListCommand(), newClearCommand(), ) diff --git a/cmd/sava/root_test.go b/cmd/sava/root_test.go index 5cbc9c1..d99d90c 100644 --- a/cmd/sava/root_test.go +++ b/cmd/sava/root_test.go @@ -58,6 +58,87 @@ func TestAddListFlow(t *testing.T) { } } +func TestStartFlow(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + mustExecute(t, "add", "-s", "slice cabbage") + + out := mustExecute(t, "list") + if !strings.Contains(out, "[>] slice cabbage") { + t.Errorf("task added with -s should be shown as started:\n%s", out) + } + + if _, err := execute(t, "add", "-m", "-s", "impossible"); err == nil { + t.Error("add -m -s should fail as mutually exclusive") + } + + if _, err := execute(t, "start", "no-such-hash"); err == nil { + t.Error("start with an unknown hash should fail") + } +} + +func TestTagFlow(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + mustExecute(t, "add", "-t", "cabbage,food", "buy cabbage") + + out := mustExecute(t, "list", "-t", "cabbage") + if !strings.Contains(out, "buy cabbage") || !strings.Contains(out, "#cabbage #food") { + t.Errorf("tagged item should be listed with tags:\n%s", out) + } + + out = mustExecute(t, "list", "-t", "no-such-tag") + if strings.Contains(out, "buy cabbage") { + t.Errorf("unmatched tag filter should hide the item:\n%s", out) + } + + out = mustExecute(t, "tag", "--list") + if !strings.Contains(out, "- cabbage (1)") || !strings.Contains(out, "- food (1)") { + t.Errorf("tag --list output:\n%s", out) + } + + if _, err := execute(t, "tag", "only-hash"); err == nil { + t.Error("tag without tags should fail") + } +} + +func TestDiffFlow(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + mustExecute(t, "add", "first task") + mustExecute(t, "add", "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]) + } + } + if len(hashes) != 2 { + t.Fatalf("hashes = %+v, want 2:\n%s", hashes, list) + } + + out := mustExecute(t, "diff", hashes[0]+"..."+hashes[1]) + if !strings.Contains(out, "Diff...") || !strings.Contains(out, "Elapsed: ") { + t.Errorf("diff output:\n%s", out) + } + + // Two-argument form works as well. + out = mustExecute(t, "diff", hashes[0], hashes[1]) + if !strings.Contains(out, "Elapsed: ") { + t.Errorf("two-arg diff output:\n%s", out) + } + + if _, err := execute(t, "diff", "lonely-hash"); err == nil { + t.Error("diff without a separator should fail") + } + if _, err := execute(t, "diff", hashes[0]+"...no-such-hash"); err == nil { + t.Error("diff with an unknown hash should fail") + } +} + func TestClearAllWithYes(t *testing.T) { t.Setenv("HOME", t.TempDir()) diff --git a/docs/update-command-and-features.md b/docs/update-command-and-features.md index 313aaef..93b32f4 100644 --- a/docs/update-command-and-features.md +++ b/docs/update-command-and-features.md @@ -37,4 +37,69 @@ MemoItem への tag づけ機能 * 複数の tag を自由につけることができるようにし、アイテムのフィルタリングなどもできるようにする * 存在する tag は後で管理するのが大変になるので、tagづけするコマンドが走ったらインデックス相当のファイルを吐き出しておくといいかも * 複数タグ付けができる -* list時には曖昧検索、AND, OR検索をサポートしたい \ No newline at end of file +* list時には曖昧検索、AND, OR検索をサポートしたい + +--- + +## 再設計の決定事項 (2026-07-11) + +### 方針 +* データモデルは「フィールド追加」方式を採用する + * `Item` に `startedAt` / `tags` を `omitempty` で追加し、旧 Deno 実装の JSON との互換を維持する + * 状態は `closed` / `startedAt` の組合せで導出する(status enum への移行は見送り) +* タグ付けは専用コマンド (`sava tag`) と `add -t` の両方をサポートする +* 実装順は start → tag → diff + +### コマンド体系(目標) +``` +sava add # タスク追加 +sava add -s # タスク追加 + 即着手 +sava add -m # メモ追加 +sava add -t # タグ付きで追加(-t 複数指定可) +sava start # タスク着手(startedAt を記録) +sava end # タスク完了 +sava del # アイテム削除 +sava tag ... # タグ付与(インデックス更新) +sava tag -d ... # タグ除去 +sava tag --list # 既存タグ一覧 +sava list [date] [-t ] # タグフィルタ(複数は AND、--or で OR) +sava diff ... # 2アイテム間の経過時間 +sava clear [-a] # 既存のまま +``` + +### インデックスファイル +* `~/.log/sava/index.json` に `tag → [{date, hash}]` と `hash → date` の逆引きを保持 +* あくまでキャッシュ扱い: 壊れたら全ログから再構築できる +* `list` のタグフィルタと `diff` の日またぎ hash 解決の両方が乗る + +### diff の仕様(Phase 3 で確定) +* `sava diff ...`(`..` 区切り、ハッシュ2引数もサポート) +* `A.createdAt` と `B.createdAt` の距離(絶対値)を出力。順序を入れ替えても結果は同じ +* 1タスク内の作業時間は `startedAt` / `updatedAt` で足りるため、diff は「アイテム間の距離」を測る道具と位置づける +* hash はインデックスの `hash → date` 逆引きで日またぎ解決する + * インデックスにない・古い場合は一度だけ再構築してリトライ(self-heal) +* 経過時間の表示は非ゼロ成分のみ: `1d 2h 30m`, `45m 10s`, `0s` など + +### タグの仕様(Phase 2 で確定) +* タグは任意のアイテム(Task / Memo)に付けられる +* タグは trim され重複排除される。空文字と空白を含むタグはエラー +* `sava tag` が操作できるのは当日ログのアイテムのみ(end / del と同じ制約) +* `list -t` は単日表示専用。`-a` との併用はエラー(インデックス活用は今後の課題) +* インデックスはタグ操作時に全ログから再生成される。del / clear 後は古くなり得るが、 + 読む側(`tag --list`、将来の `diff`)が再生成・self-heal する方針 +* `clear -a` は index.json も削除する + +### レガシー互換の廃止 (2026-07-11) +* Go 移行完了により「旧 Deno 実装との互換」という枠組みを廃止 +* 今後の不変条件は「旧バージョンの自分が書いたファイルを読めること」 + (新フィールドは omitempty で追加する、で満たされる) +* `testdata/legacy-samples/` は現行フォーマットの仕様サンプル + `testdata/log-format/` に置き換え、round-trip テストをフォーマット仕様テストとして再定義 + +### 進捗 +* [x] Phase 1: `startedAt` + `sava start` + `add -s`(list に `[>]` マーク表示) +* [x] Phase 2: タグ(`tag` コマンド、`add -t`、`index.json`、`list -t` / `--or` フィルタ) +* [x] Phase 3: `diff`(index の hash → date 逆引きによる日またぎ解決、self-heal つき) +* [x] レガシー(Deno 互換)の廃止とテストの現行フォーマット移行 +* 中断 (pause) は優先度低のため見送り +* `end` 時の自動メモは updatedAt があるため不要と判断 \ No newline at end of file diff --git a/internal/command/command.go b/internal/command/command.go index fa6be4a..7013edd 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -8,9 +8,11 @@ import ( "errors" "fmt" "io" + "sort" "strings" "time" + "github.com/rn404/nippo-cli/internal/index" "github.com/rn404/nippo-cli/internal/log" "github.com/rn404/nippo-cli/internal/logfile" "github.com/rn404/nippo-cli/internal/model" @@ -25,15 +27,200 @@ const ( fileStatsLimit = 10 ) -// Add appends a task (or a memo when memo is true) to today's log. -func Add(dir, content string, memo bool) error { +// AddOptions controls the add command behavior. +type AddOptions struct { + Memo bool // add a memo instead of a task + Start bool // mark the task as started right away + Tags []string // tags to put on the new item +} + +// Add appends a task (or a memo) to today's log. +func Add(dir, content string, opts AddOptions) error { + if opts.Memo && opts.Start { + return errors.New("a memo cannot be started") + } + + file, err := logfile.Get(dir, "") + if err != nil { + return err + } + item, err := log.Add(&file.Body, content, !opts.Memo) + if err != nil { + return err + } + if opts.Start { + if _, err := log.Start(&file.Body, item.Hash); err != nil { + return err + } + } + if len(opts.Tags) > 0 { + if _, err := log.AddTags(&file.Body, item.Hash, opts.Tags); err != nil { + return err + } + } + + if err := logfile.Update(dir, file.Name, file.Body); err != nil { + return err + } + if len(opts.Tags) > 0 { + if _, err := index.Rebuild(dir); err != nil { + return err + } + } + return nil +} + +// Tag adds tags to (or removes them from, when remove is true) the +// item matching hash in today's log, then refreshes the index. +func Tag(w io.Writer, dir, hash string, tags []string, remove bool) error { file, err := logfile.Get(dir, "") if err != nil { return err } - if err := log.Add(&file.Body, content, !memo); err != nil { + + var item model.Item + if remove { + item, err = log.RemoveTags(&file.Body, hash, tags) + } else { + item, err = log.AddTags(&file.Body, hash, tags) + } + if err != nil { + return err + } + + if err := logfile.Update(dir, file.Name, file.Body); err != nil { + return err + } + if _, err := index.Rebuild(dir); err != nil { + return err + } + + view.TagsUpdated(w, item) + return nil +} + +// TagList prints every known tag with its item count, refreshing the +// index as a side effect. +func TagList(w io.Writer, dir string) error { + idx, err := index.Rebuild(dir) + if err != nil { return err } + + view.Header(w, "Known tags are...") + if len(idx.Tags) == 0 { + fmt.Fprintln(w, "There is no tags...") + return nil + } + + names := make([]string, 0, len(idx.Tags)) + for name := range idx.Tags { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + view.ListItem(w, fmt.Sprintf("%s (%d)", name, len(idx.Tags[name]))) + } + return nil +} + +// Diff prints the elapsed time between the creation of two items, +// resolving each hash across all daily logs via the index. The index +// is rebuilt once when a hash is not found, so stale cache entries +// heal themselves. +func Diff(w io.Writer, dir, hashA, hashB string) error { + idx, err := index.Load(dir) + if err != nil { + return err + } + + rebuilt := false + resolve := func(hash string) (model.Item, error) { + for { + if item, ok := lookup(dir, idx, hash); ok { + return item, nil + } + if rebuilt { + return model.Item{}, fmt.Errorf("target item %q is not found", hash) + } + idx, err = index.Rebuild(dir) + if err != nil { + return model.Item{}, err + } + rebuilt = true + } + } + + itemA, err := resolve(hashA) + if err != nil { + return err + } + itemB, err := resolve(hashB) + if err != nil { + return err + } + + elapsed, err := elapsedBetween(itemA, itemB) + if err != nil { + return err + } + + view.Diff(w, itemA, itemB, elapsed) + return nil +} + +// lookup finds the item behind hash using the index. A stale entry +// (missing file or hash no longer in it) reports a miss. +func lookup(dir string, idx index.Index, hash string) (model.Item, bool) { + date, ok := idx.Hashes[hash] + if !ok { + return model.Item{}, false + } + + file, err := logfile.Stat(dir, date) + if err != nil { + return model.Item{}, false + } + for _, item := range file.Body.Items { + if item.Hash == hash { + return item, true + } + } + return model.Item{}, false +} + +// elapsedBetween returns the absolute distance between the creation +// times of two items. +func elapsedBetween(a, b model.Item) (time.Duration, error) { + createdA, err := time.Parse(time.RFC3339, a.CreatedAt) + if err != nil { + return 0, fmt.Errorf("broken createdAt on item %q: %w", a.Hash, err) + } + createdB, err := time.Parse(time.RFC3339, b.CreatedAt) + if err != nil { + return 0, fmt.Errorf("broken createdAt on item %q: %w", b.Hash, err) + } + + elapsed := createdB.Sub(createdA) + if elapsed < 0 { + elapsed = -elapsed + } + return elapsed, nil +} + +// Start marks the task matching hash in today's log as started. +func Start(w io.Writer, dir, hash string) error { + file, err := logfile.Get(dir, "") + if err != nil { + return err + } + + started, err := log.Start(&file.Body, hash) + if err != nil { + return err + } + + view.StartedTask(w, started) return logfile.Update(dir, file.Name, file.Body) } @@ -69,7 +256,9 @@ type ListOptions struct { Date string // yyyy-MM-dd; empty means today All bool Stat bool - Yes bool // skip confirmation prompts + Yes bool // skip confirmation prompts + Tags []string // show only items carrying the tags + Or bool // match any tag instead of all } // List shows the items of one day, or summaries across all log files. @@ -77,6 +266,9 @@ func List(w io.Writer, r io.Reader, dir string, opts ListOptions) error { if !opts.All { return listOneDay(w, dir, opts) } + if len(opts.Tags) > 0 { + return errors.New("tag filter cannot be combined with --all") + } refs, err := logfile.List(dir) if err != nil { @@ -143,6 +335,10 @@ func listOneDay(w io.Writer, dir string, opts ListOptions) error { } tasks, memos := log.Split(file.Body) + if len(opts.Tags) > 0 { + tasks = log.FilterByTags(tasks, opts.Tags, opts.Or) + memos = log.FilterByTags(memos, opts.Tags, opts.Or) + } view.ItemList(w, tasks, memos) return nil } @@ -182,7 +378,7 @@ func clearAll(w io.Writer, r io.Reader, dir string, yes bool) error { } fmt.Fprintln(w, "Deleted all files.") - return nil + return index.Remove(dir) } func clearOld(w io.Writer, dir string) error { diff --git a/internal/command/command_test.go b/internal/command/command_test.go index 3ea23b9..24b46f0 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/rn404/nippo-cli/internal/index" "github.com/rn404/nippo-cli/internal/logfile" "github.com/rn404/nippo-cli/internal/model" ) @@ -27,10 +28,10 @@ func todayItems(t *testing.T, dir string) []model.Item { func TestAddEndDelFlow(t *testing.T) { dir := t.TempDir() - if err := Add(dir, "buy cabbage", false); err != nil { + if err := Add(dir, "buy cabbage", AddOptions{}); err != nil { t.Fatal(err) } - if err := Add(dir, "a memo", true); err != nil { + if err := Add(dir, "a memo", AddOptions{Memo: true}); err != nil { t.Fatal(err) } @@ -64,7 +65,7 @@ func TestAddEndDelFlow(t *testing.T) { func TestEndErrors(t *testing.T) { dir := t.TempDir() - if err := Add(dir, "a memo", true); err != nil { + if err := Add(dir, "a memo", AddOptions{Memo: true}); err != nil { t.Fatal(err) } memo := todayItems(t, dir)[0] @@ -78,12 +79,217 @@ func TestEndErrors(t *testing.T) { } } +func TestStartFlow(t *testing.T) { + dir := t.TempDir() + + if err := Add(dir, "slice cabbage", AddOptions{}); err != nil { + t.Fatal(err) + } + task := todayItems(t, dir)[0] + + var out strings.Builder + if err := Start(&out, dir, task.Hash); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Started!!") { + t.Errorf("Start output = %q", out.String()) + } + if items := todayItems(t, dir); !items[0].IsStarted() { + t.Errorf("task should be started after Start: %+v", items[0]) + } + + if err := Start(&out, dir, task.Hash); err == nil { + t.Errorf("starting the same task twice should fail") + } +} + +func TestAddWithStart(t *testing.T) { + dir := t.TempDir() + + if err := Add(dir, "feed the shrimp", AddOptions{Start: true}); err != nil { + t.Fatal(err) + } + if items := todayItems(t, dir); !items[0].IsStarted() { + t.Errorf("task added with start should be started: %+v", items[0]) + } + + if err := Add(dir, "a memo", AddOptions{Memo: true, Start: true}); err == nil { + t.Errorf("memo with start should fail") + } +} + +func TestTagFlow(t *testing.T) { + dir := t.TempDir() + if err := Add(dir, "buy cabbage", AddOptions{Tags: []string{"cabbage", "shopping"}}); err != nil { + t.Fatal(err) + } + item := todayItems(t, dir)[0] + if len(item.Tags) != 2 { + t.Fatalf("tags = %+v, want 2", item.Tags) + } + if _, err := os.Stat(index.Path(dir)); err != nil { + t.Errorf("add with tags should write the index: %v", err) + } + + var out strings.Builder + if err := Tag(&out, dir, item.Hash, []string{"food"}, false); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Tags updated!!") || !strings.Contains(out.String(), "#food") { + t.Errorf("Tag output = %q", out.String()) + } + + out.Reset() + if err := Tag(&out, dir, item.Hash, []string{"shopping"}, true); err != nil { + t.Fatal(err) + } + if item := todayItems(t, dir)[0]; item.HasTag("shopping") || !item.HasTag("food") { + t.Errorf("shopping should be removed, food kept: %+v", item.Tags) + } + + if err := Tag(&out, dir, "no-such-hash", []string{"x"}, false); err == nil { + t.Errorf("tagging unknown hash should fail") + } +} + +func TestTagList(t *testing.T) { + dir := t.TempDir() + + var out strings.Builder + if err := TagList(&out, dir); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "There is no tags...") { + t.Errorf("empty TagList output = %q", out.String()) + } + + if err := Add(dir, "buy cabbage", AddOptions{Tags: []string{"cabbage"}}); err != nil { + t.Fatal(err) + } + if err := Add(dir, "more cabbage", AddOptions{Tags: []string{"cabbage"}}); err != nil { + t.Fatal(err) + } + + out.Reset() + if err := TagList(&out, dir); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "- cabbage (2)") { + t.Errorf("TagList output = %q", out.String()) + } +} + +func TestListWithTagFilter(t *testing.T) { + dir := t.TempDir() + for content, tags := range map[string][]string{ + "tagged both": {"go", "cli"}, + "tagged one": {"go"}, + "tagged other": {"web"}, + } { + if err := Add(dir, content, AddOptions{Tags: tags}); err != nil { + t.Fatal(err) + } + } + + var out strings.Builder + if err := List(&out, strings.NewReader(""), dir, ListOptions{Tags: []string{"go", "cli"}}); err != nil { + t.Fatal(err) + } + if got := out.String(); !strings.Contains(got, "tagged both") || strings.Contains(got, "tagged one") { + t.Errorf("AND filter output = %q", got) + } + + out.Reset() + if err := List(&out, strings.NewReader(""), dir, ListOptions{Tags: []string{"go", "cli"}, Or: true}); err != nil { + t.Fatal(err) + } + if got := out.String(); !strings.Contains(got, "tagged one") || strings.Contains(got, "tagged other") { + t.Errorf("OR filter output = %q", got) + } + + if err := List(&out, strings.NewReader(""), dir, ListOptions{All: true, Tags: []string{"go"}}); err == nil { + t.Errorf("tag filter with --all should fail") + } +} + +// writeDay stores items as the log of day, bypassing Add so tests can +// control hashes and timestamps. +func writeDay(t *testing.T, dir, day string, items []model.Item) { + t.Helper() + file, err := logfile.Get(dir, day) + if err != nil { + t.Fatal(err) + } + file.Body.Items = items + if err := logfile.Update(dir, day, file.Body); err != nil { + t.Fatal(err) + } +} + +func TestDiffAcrossDays(t *testing.T) { + dir := t.TempDir() + writeDay(t, dir, "2026-07-05", []model.Item{ + {Hash: "aaaa1111", Content: "buy cabbage", CreatedAt: "2026-07-05T10:00:00.000Z", UpdatedAt: "2026-07-05T10:00:00.000Z"}, + }) + writeDay(t, dir, "2026-07-06", []model.Item{ + {Hash: "bbbb2222", Content: "feed the shrimp", CreatedAt: "2026-07-06T12:30:00.000Z", UpdatedAt: "2026-07-06T12:30:00.000Z"}, + }) + + // No index file exists yet: Diff must rebuild it by itself. + var out strings.Builder + if err := Diff(&out, dir, "aaaa1111", "bbbb2222"); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Elapsed: 1d 2h 30m") { + t.Errorf("Diff output = %q", out.String()) + } + if _, err := os.Stat(index.Path(dir)); err != nil { + t.Errorf("Diff should persist the rebuilt index: %v", err) + } + + // Reversed order measures the same distance. + out.Reset() + if err := Diff(&out, dir, "bbbb2222", "aaaa1111"); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Elapsed: 1d 2h 30m") { + t.Errorf("reversed Diff output = %q", out.String()) + } + + if err := Diff(&out, dir, "aaaa1111", "no-such-hash"); err == nil { + t.Errorf("Diff with unknown hash should fail") + } +} + +func TestDiffHealsStaleIndex(t *testing.T) { + dir := t.TempDir() + writeDay(t, dir, "2026-07-05", []model.Item{ + {Hash: "aaaa1111", Content: "buy cabbage", CreatedAt: "2026-07-05T10:00:00.000Z", UpdatedAt: "2026-07-05T10:00:00.000Z"}, + }) + if _, err := index.Rebuild(dir); err != nil { + t.Fatal(err) + } + + // The item appears after the index was built: a stale cache miss. + writeDay(t, dir, "2026-07-06", []model.Item{ + {Hash: "bbbb2222", Content: "feed the shrimp", CreatedAt: "2026-07-06T10:00:00.000Z", UpdatedAt: "2026-07-06T10:00:00.000Z"}, + }) + + var out strings.Builder + if err := Diff(&out, dir, "aaaa1111", "bbbb2222"); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Elapsed: 1d") { + t.Errorf("Diff should heal the stale index: %q", out.String()) + } +} + func TestListToday(t *testing.T) { dir := t.TempDir() - if err := Add(dir, "buy cabbage", false); err != nil { + if err := Add(dir, "buy cabbage", AddOptions{}); err != nil { t.Fatal(err) } - if err := Add(dir, "shrimp memo", true); err != nil { + if err := Add(dir, "shrimp memo", AddOptions{Memo: true}); err != nil { t.Fatal(err) } @@ -117,7 +323,7 @@ func TestListEmptyAndInvalidDate(t *testing.T) { func TestListStatAndAll(t *testing.T) { dir := t.TempDir() - if err := Add(dir, "buy cabbage", false); err != nil { + if err := Add(dir, "buy cabbage", AddOptions{}); err != nil { t.Fatal(err) } @@ -179,7 +385,7 @@ func TestClearOld(t *testing.T) { if _, err := logfile.Get(dir, "2000-01-01"); err != nil { t.Fatal(err) } - if err := Add(dir, "recent", false); err != nil { + if err := Add(dir, "recent", AddOptions{}); err != nil { t.Fatal(err) } @@ -199,13 +405,13 @@ func TestClearOld(t *testing.T) { t.Errorf("only today's file should remain, got %+v", refs) } if _, err := os.Stat(filepath.Join(dir, "2000-01-01.json")); !os.IsNotExist(err) { - t.Errorf("old file should actually be removed from disk (Deno version bug)") + t.Errorf("old file should actually be removed from disk") } } func TestClearAll(t *testing.T) { dir := t.TempDir() - if err := Add(dir, "content", false); err != nil { + if err := Add(dir, "content", AddOptions{}); err != nil { t.Fatal(err) } diff --git a/internal/index/index.go b/internal/index/index.go new file mode 100644 index 0000000..b67cbf7 --- /dev/null +++ b/internal/index/index.go @@ -0,0 +1,125 @@ +// Package index maintains index.json in the log directory: a cache +// mapping tags and item hashes to the daily log files containing them. +// The file is rebuildable from the logs at any time, so it may go +// stale after item deletion; readers should rebuild on a miss instead +// of trusting it blindly. +package index + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + + "github.com/rn404/nippo-cli/internal/logfile" +) + +const fileName = "index.json" + +// Entry points to one item inside a daily log file. +type Entry struct { + Date string `json:"date"` + Hash string `json:"hash"` +} + +// Index is the persisted index body. +type Index struct { + // Tags maps a tag to the items carrying it. + Tags map[string][]Entry `json:"tags"` + // Hashes maps an item hash to the date (file name) holding it. + Hashes map[string]string `json:"hashes"` +} + +// Path returns the index file location inside the log directory. +func Path(dir string) string { + return filepath.Join(dir, fileName) +} + +// Build scans every daily log file and returns a fresh index. +func Build(dir string) (Index, error) { + idx := Index{ + Tags: map[string][]Entry{}, + Hashes: map[string]string{}, + } + + refs, err := logfile.List(dir) + if err != nil { + return Index{}, err + } + + for _, ref := range refs { + file, err := logfile.Stat(dir, ref.Name) + if err != nil { + return Index{}, err + } + for _, item := range file.Body.Items { + idx.Hashes[item.Hash] = file.Name + for _, tag := range item.Tags { + idx.Tags[tag] = append(idx.Tags[tag], Entry{Date: file.Name, Hash: item.Hash}) + } + } + } + + return idx, nil +} + +// Load reads the persisted index. A missing or broken file yields an +// empty index rather than an error: the file is a cache, and callers +// are expected to Rebuild on a miss anyway. +func Load(dir string) (Index, error) { + empty := Index{Tags: map[string][]Entry{}, Hashes: map[string]string{}} + + data, err := os.ReadFile(Path(dir)) + if errors.Is(err, os.ErrNotExist) { + return empty, nil + } + if err != nil { + return Index{}, err + } + + var idx Index + if err := json.Unmarshal(data, &idx); err != nil { + return empty, nil + } + if idx.Tags == nil { + idx.Tags = map[string][]Entry{} + } + if idx.Hashes == nil { + idx.Hashes = map[string]string{} + } + return idx, nil +} + +// Rebuild builds a fresh index and persists it. +func Rebuild(dir string) (Index, error) { + idx, err := Build(dir) + if err != nil { + return Index{}, err + } + if err := save(dir, idx); err != nil { + return Index{}, err + } + return idx, nil +} + +// Remove deletes the index file; a missing file is not an error. +func Remove(dir string) error { + err := os.Remove(Path(dir)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +func save(dir string, idx Index) error { + data, err := json.MarshalIndent(idx, "", " ") + if err != nil { + return err + } + + // Same ownership rules as the daily logs themselves. + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + return os.WriteFile(Path(dir), data, 0o600) +} diff --git a/internal/index/index_test.go b/internal/index/index_test.go new file mode 100644 index 0000000..4665dd9 --- /dev/null +++ b/internal/index/index_test.go @@ -0,0 +1,146 @@ +package index + +import ( + "encoding/json" + "os" + "testing" + + "github.com/rn404/nippo-cli/internal/log" + "github.com/rn404/nippo-cli/internal/logfile" +) + +func addTaggedItem(t *testing.T, dir, day, content string, tags []string) string { + t.Helper() + + file, err := logfile.Get(dir, day) + if err != nil { + t.Fatal(err) + } + item, err := log.Add(&file.Body, content, true) + if err != nil { + t.Fatal(err) + } + if len(tags) > 0 { + if _, err := log.AddTags(&file.Body, item.Hash, tags); err != nil { + t.Fatal(err) + } + } + if err := logfile.Update(dir, day, file.Body); err != nil { + t.Fatal(err) + } + return item.Hash +} + +func TestBuildAndRebuild(t *testing.T) { + dir := t.TempDir() + first := addTaggedItem(t, dir, "2026-07-10", "buy cabbage", []string{"cabbage"}) + second := addTaggedItem(t, dir, "2026-07-11", "feed the shrimp", []string{"shrimp", "pet"}) + plain := addTaggedItem(t, dir, "2026-07-11", "no tags here", nil) + + idx, err := Rebuild(dir) + if err != nil { + t.Fatal(err) + } + + if len(idx.Tags) != 3 { + t.Errorf("tags = %+v, want cabbage, shrimp, pet", idx.Tags) + } + if entries := idx.Tags["cabbage"]; len(entries) != 1 || entries[0].Hash != first || entries[0].Date != "2026-07-10" { + t.Errorf("cabbage entries = %+v", entries) + } + for hash, date := range map[string]string{first: "2026-07-10", second: "2026-07-11", plain: "2026-07-11"} { + if idx.Hashes[hash] != date { + t.Errorf("Hashes[%s] = %q, want %q", hash, idx.Hashes[hash], date) + } + } + + // Rebuild persists the index as readable JSON next to the logs. + data, err := os.ReadFile(Path(dir)) + if err != nil { + t.Fatal(err) + } + var reloaded Index + if err := json.Unmarshal(data, &reloaded); err != nil { + t.Fatalf("index file should be valid JSON: %v", err) + } + if len(reloaded.Hashes) != 3 { + t.Errorf("persisted hashes = %+v, want 3", reloaded.Hashes) + } +} + +func TestBuildEmptyDir(t *testing.T) { + idx, err := Build(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if len(idx.Tags) != 0 || len(idx.Hashes) != 0 { + t.Errorf("empty dir should yield an empty index: %+v", idx) + } +} + +func TestIndexFileIsNotListedAsLog(t *testing.T) { + dir := t.TempDir() + addTaggedItem(t, dir, "2026-07-11", "content", []string{"go"}) + if _, err := Rebuild(dir); err != nil { + t.Fatal(err) + } + + refs, err := logfile.List(dir) + if err != nil { + t.Fatal(err) + } + if len(refs) != 1 || refs[0].Name != "2026-07-11" { + t.Errorf("index.json must not be listed as a daily log: %+v", refs) + } +} + +func TestLoad(t *testing.T) { + dir := t.TempDir() + + // Missing file yields an empty, usable index. + idx, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if idx.Tags == nil || idx.Hashes == nil { + t.Errorf("Load should return non-nil maps: %+v", idx) + } + + // A broken file is treated as an empty cache, not an error. + if err := os.WriteFile(Path(dir), []byte("not json"), 0o600); err != nil { + t.Fatal(err) + } + if idx, err = Load(dir); err != nil || idx.Tags == nil || idx.Hashes == nil { + t.Errorf("broken index should load as empty: %+v, %v", idx, err) + } + + // A persisted index round-trips. + hash := addTaggedItem(t, dir, "2026-07-11", "content", []string{"go"}) + if _, err := Rebuild(dir); err != nil { + t.Fatal(err) + } + idx, err = Load(dir) + if err != nil { + t.Fatal(err) + } + if idx.Hashes[hash] != "2026-07-11" || len(idx.Tags["go"]) != 1 { + t.Errorf("loaded index mismatch: %+v", idx) + } +} + +func TestRemove(t *testing.T) { + dir := t.TempDir() + if err := Remove(dir); err != nil { + t.Errorf("removing a missing index should not fail: %v", err) + } + + if _, err := Rebuild(dir); err != nil { + t.Fatal(err) + } + if err := Remove(dir); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(Path(dir)); !os.IsNotExist(err) { + t.Errorf("index file should be gone, got %v", err) + } +} diff --git a/internal/log/log.go b/internal/log/log.go index f3979d1..3dcea9e 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "sort" + "strings" "github.com/rn404/nippo-cli/internal/model" ) @@ -16,20 +17,26 @@ var ( ErrNotTask = errors.New("target item is not a task") // ErrAlreadyFinished is returned when finishing a closed task. ErrAlreadyFinished = errors.New("target item is already finished") + // ErrAlreadyStarted is returned when starting a started task. + ErrAlreadyStarted = errors.New("target item is already started") + // ErrEmptyTag is returned when a tag is empty after trimming. + ErrEmptyTag = errors.New("tag must not be empty") ) -// Add appends a new task or memo to the log. -func Add(l *model.Log, content string, isTask bool) error { +// Add appends a new task or memo to the log and returns the created item. +func Add(l *model.Log, content string, isTask bool) (model.Item, error) { if l.Freezed { - return ErrFreezed + return model.Item{}, ErrFreezed } + var item model.Item if isTask { - l.Items = append(l.Items, model.NewTaskItem(content)) + item = model.NewTaskItem(content) } else { - l.Items = append(l.Items, model.NewMemoItem(content)) + item = model.NewMemoItem(content) } - return nil + l.Items = append(l.Items, item) + return item, nil } // Delete removes all items matching hash from the log. @@ -66,6 +73,140 @@ func Finish(l *model.Log, hash string) (model.Item, error) { return model.Item{}, fmt.Errorf("target item %q is not found", hash) } +// Start marks the task matching hash as started and returns the +// updated item. +func Start(l *model.Log, hash string) (model.Item, error) { + for i, item := range l.Items { + if item.Hash != hash { + continue + } + if !item.IsTask() { + return model.Item{}, ErrNotTask + } + if item.IsClosed() { + return model.Item{}, ErrAlreadyFinished + } + if item.IsStarted() { + return model.Item{}, ErrAlreadyStarted + } + + now := model.NowISO() + item.StartedAt = &now + item.UpdatedAt = now + l.Items[i] = item + return item, nil + } + + return model.Item{}, fmt.Errorf("target item %q is not found", hash) +} + +// normalizeTags trims whitespace and deduplicates tags while keeping +// their order. Empty tags and tags containing whitespace are rejected. +func normalizeTags(tags []string) ([]string, error) { + seen := map[string]bool{} + out := make([]string, 0, len(tags)) + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "" { + return nil, ErrEmptyTag + } + if strings.ContainsAny(tag, " \t") { + return nil, fmt.Errorf("tag %q must not contain whitespace", tag) + } + if !seen[tag] { + seen[tag] = true + out = append(out, tag) + } + } + return out, nil +} + +// AddTags adds tags to the item matching hash (tasks and memos alike) +// and returns the updated item. Already present tags are skipped. +func AddTags(l *model.Log, hash string, tags []string) (model.Item, error) { + tags, err := normalizeTags(tags) + if err != nil { + return model.Item{}, err + } + + for i, item := range l.Items { + if item.Hash != hash { + continue + } + + changed := false + for _, tag := range tags { + if !item.HasTag(tag) { + item.Tags = append(item.Tags, tag) + changed = true + } + } + if changed { + item.UpdatedAt = model.NowISO() + } + l.Items[i] = item + return item, nil + } + + return model.Item{}, fmt.Errorf("target item %q is not found", hash) +} + +// RemoveTags removes tags from the item matching hash and returns the +// updated item. Tags the item does not carry are ignored. +func RemoveTags(l *model.Log, hash string, tags []string) (model.Item, error) { + tags, err := normalizeTags(tags) + if err != nil { + return model.Item{}, err + } + + drop := map[string]bool{} + for _, tag := range tags { + drop[tag] = true + } + + for i, item := range l.Items { + if item.Hash != hash { + continue + } + + kept := item.Tags[:0] + for _, tag := range item.Tags { + if !drop[tag] { + kept = append(kept, tag) + } + } + if len(kept) != len(item.Tags) { + item.UpdatedAt = model.NowISO() + } + if len(kept) == 0 { + kept = nil + } + item.Tags = kept + l.Items[i] = item + return item, nil + } + + return model.Item{}, fmt.Errorf("target item %q is not found", hash) +} + +// FilterByTags returns the items matching the tags: all of them by +// default, or at least one when anyMatch is true. +func FilterByTags(items []model.Item, tags []string, anyMatch bool) []model.Item { + var out []model.Item + for _, item := range items { + matched := 0 + for _, tag := range tags { + if item.HasTag(tag) { + matched++ + } + } + if (anyMatch && matched > 0) || (!anyMatch && matched == len(tags)) { + out = append(out, item) + } + } + return out +} + // Split separates the log items into tasks and memos, each sorted by // creation time in ascending order. func Split(l model.Log) (tasks, memos []model.Item) { diff --git a/internal/log/log_test.go b/internal/log/log_test.go index 1955d77..50547df 100644 --- a/internal/log/log_test.go +++ b/internal/log/log_test.go @@ -23,10 +23,11 @@ func newTestLog() model.Log { func TestAdd(t *testing.T) { l := newTestLog() - if err := Add(&l, "new task", true); err != nil { + task, err := Add(&l, "new task", true) + if err != nil { t.Fatal(err) } - if err := Add(&l, "new memo", false); err != nil { + if _, err := Add(&l, "new memo", false); err != nil { t.Fatal(err) } @@ -36,6 +37,9 @@ func TestAdd(t *testing.T) { if !l.Items[3].IsTask() { t.Errorf("appended item should be a task: %+v", l.Items[3]) } + if l.Items[3].Hash != task.Hash { + t.Errorf("Add should return the appended item: %+v", task) + } if l.Items[4].IsTask() { t.Errorf("appended item should be a memo: %+v", l.Items[4]) } @@ -44,7 +48,7 @@ func TestAdd(t *testing.T) { func TestAddToFreezedLog(t *testing.T) { l := newTestLog() l.Freezed = true - if err := Add(&l, "content", true); !errors.Is(err, ErrFreezed) { + if _, err := Add(&l, "content", true); !errors.Is(err, ErrFreezed) { t.Errorf("err = %v, want ErrFreezed", err) } } @@ -98,6 +102,119 @@ func TestFinishErrors(t *testing.T) { } } +func TestStart(t *testing.T) { + l := newTestLog() + started, err := Start(&l, "task-open") + if err != nil { + t.Fatal(err) + } + if !started.IsStarted() { + t.Errorf("started item should have startedAt: %+v", started) + } + if started.UpdatedAt != *started.StartedAt { + t.Errorf("updatedAt should match startedAt on start: %+v", started) + } + if !l.Items[0].IsStarted() { + t.Errorf("log should hold the started item: %+v", l.Items[0]) + } +} + +func TestStartErrors(t *testing.T) { + l := newTestLog() + + if _, err := Start(&l, "no-such-hash"); err == nil { + t.Errorf("starting unknown hash should fail") + } + if _, err := Start(&l, "memo-1"); !errors.Is(err, ErrNotTask) { + t.Errorf("err = %v, want ErrNotTask", err) + } + if _, err := Start(&l, "task-done"); !errors.Is(err, ErrAlreadyFinished) { + t.Errorf("err = %v, want ErrAlreadyFinished", err) + } + + if _, err := Start(&l, "task-open"); err != nil { + t.Fatal(err) + } + if _, err := Start(&l, "task-open"); !errors.Is(err, ErrAlreadyStarted) { + t.Errorf("err = %v, want ErrAlreadyStarted", err) + } +} + +func TestAddAndRemoveTags(t *testing.T) { + l := newTestLog() + + tagged, err := AddTags(&l, "memo-1", []string{"shrimp", " pet ", "shrimp"}) + if err != nil { + t.Fatal(err) + } + if len(tagged.Tags) != 2 || !tagged.HasTag("shrimp") || !tagged.HasTag("pet") { + t.Errorf("tags should be trimmed and deduplicated: %+v", tagged.Tags) + } + if tagged.UpdatedAt == tagged.CreatedAt { + t.Errorf("updatedAt should be renewed on tagging") + } + + // Adding an existing tag is a no-op for that tag. + tagged, err = AddTags(&l, "memo-1", []string{"shrimp", "happy"}) + if err != nil { + t.Fatal(err) + } + if len(tagged.Tags) != 3 { + t.Errorf("tags = %+v, want 3 entries", tagged.Tags) + } + + removed, err := RemoveTags(&l, "memo-1", []string{"pet", "unknown"}) + if err != nil { + t.Fatal(err) + } + if len(removed.Tags) != 2 || removed.HasTag("pet") { + t.Errorf("pet should be removed: %+v", removed.Tags) + } + + removed, err = RemoveTags(&l, "memo-1", []string{"shrimp", "happy"}) + if err != nil { + t.Fatal(err) + } + if removed.Tags != nil { + t.Errorf("emptied tags should marshal away entirely: %+v", removed.Tags) + } +} + +func TestTagErrors(t *testing.T) { + l := newTestLog() + + if _, err := AddTags(&l, "no-such-hash", []string{"tag"}); err == nil { + t.Errorf("tagging unknown hash should fail") + } + if _, err := AddTags(&l, "memo-1", []string{""}); !errors.Is(err, ErrEmptyTag) { + t.Errorf("err = %v, want ErrEmptyTag", err) + } + if _, err := AddTags(&l, "memo-1", []string{"has space"}); err == nil { + t.Errorf("tag with whitespace should fail") + } + if _, err := RemoveTags(&l, "no-such-hash", []string{"tag"}); err == nil { + t.Errorf("untagging unknown hash should fail") + } +} + +func TestFilterByTags(t *testing.T) { + items := []model.Item{ + {Hash: "a", Tags: []string{"go", "cli"}}, + {Hash: "b", Tags: []string{"go"}}, + {Hash: "c"}, + } + + and := FilterByTags(items, []string{"go", "cli"}, false) + if len(and) != 1 || and[0].Hash != "a" { + t.Errorf("AND filter = %+v, want only a", and) + } + + or := FilterByTags(items, []string{"go", "cli"}, true) + if len(or) != 2 { + t.Errorf("OR filter = %+v, want a and b", or) + } +} + func TestSplit(t *testing.T) { tasks, memos := Split(newTestLog()) diff --git a/internal/logfile/logfile.go b/internal/logfile/logfile.go index e6e9104..ea6d455 100644 --- a/internal/logfile/logfile.go +++ b/internal/logfile/logfile.go @@ -40,7 +40,7 @@ type Ref struct { } // Dir returns the log directory. It prefers the home directory and -// falls back to the current working directory, like the Deno version. +// falls back to the current working directory. func Dir() string { root, err := os.UserHomeDir() if err != nil || root == "" { diff --git a/internal/logfile/logfile_test.go b/internal/logfile/logfile_test.go index 065a0a3..c100cb2 100644 --- a/internal/logfile/logfile_test.go +++ b/internal/logfile/logfile_test.go @@ -62,7 +62,7 @@ func TestUpdateAndReload(t *testing.T) { t.Fatal(err) } - if err := log.Add(&file.Body, "buy cabbage", true); err != nil { + if _, err := log.Add(&file.Body, "buy cabbage", true); err != nil { t.Fatal(err) } if err := Update(dir, file.Name, file.Body); err != nil { @@ -171,21 +171,21 @@ func TestRemove(t *testing.T) { } if err := Remove(refs[0]); err != nil { - t.Fatalf("Remove should delete the actual file (Deno version bug): %v", err) + t.Fatalf("Remove should delete the actual file: %v", err) } if _, err := os.Stat(refs[0].Path); !errors.Is(err, os.ErrNotExist) { t.Errorf("file should be gone, got %v", err) } } -func TestReadLegacySample(t *testing.T) { - dir := filepath.Join("..", "..", "testdata", "legacy-samples") +func TestReadFormatSample(t *testing.T) { + dir := filepath.Join("..", "..", "testdata", "log-format") file, err := Stat(dir, "2026-07-05") if err != nil { t.Fatal(err) } if file == nil { - t.Fatal("legacy sample should be readable") + t.Fatal("format sample should be readable") } if len(file.Body.Items) != 3 { t.Errorf("items = %d, want 3", len(file.Body.Items)) diff --git a/internal/model/model.go b/internal/model/model.go index 7e97652..b543682 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -1,6 +1,7 @@ // Package model defines the log data structures persisted as JSON. -// The JSON layout must stay compatible with files written by the -// former Deno implementation (see testdata/legacy-samples/). +// The JSON layout is the storage format spec (see testdata/log-format/). +// New fields must be optional (omitempty) so files written by older +// versions stay readable. package model import ( @@ -20,13 +21,16 @@ const isoLayout = "2006-01-02T15:04:05.000Z" const idBytes = 4 // Item is a single log entry. The presence of Closed distinguishes a -// task (non-nil) from a memo (nil). +// task (non-nil) from a memo (nil). StartedAt is set when work on a +// task begins. Tags hold free-form labels on any item kind. type Item struct { - Hash string `json:"hash"` - Content string `json:"content"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - Closed *bool `json:"closed,omitempty"` + Hash string `json:"hash"` + Content string `json:"content"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + StartedAt *string `json:"startedAt,omitempty"` + Closed *bool `json:"closed,omitempty"` + Tags []string `json:"tags,omitempty"` } // NewTaskItem creates an open task with a fresh ID and timestamps. @@ -65,6 +69,21 @@ func (i Item) IsClosed() bool { return i.Closed != nil && *i.Closed } +// IsStarted reports whether work on the task has begun. +func (i Item) IsStarted() bool { + return i.StartedAt != nil +} + +// HasTag reports whether the item carries the tag. +func (i Item) HasTag(tag string) bool { + for _, t := range i.Tags { + if t == tag { + return true + } + } + return false +} + // Log is the body of one daily log file. type Log struct { Hash string `json:"hash"` diff --git a/internal/model/model_test.go b/internal/model/model_test.go index d1dbb21..e36a8e8 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -5,43 +5,58 @@ import ( "os" "path/filepath" "regexp" + "strings" "testing" ) -func TestLegacyFileRoundTrip(t *testing.T) { - path := filepath.Join("..", "..", "testdata", "legacy-samples", "2026-07-05.json") +// TestFormatSampleRoundTrip pins the storage format: the sample under +// testdata/log-format/ is the spec, and marshaling must reproduce it +// byte for byte. Item 1 carries only the required fields, proving that +// files written by older versions (before startedAt/tags) still load. +func TestFormatSampleRoundTrip(t *testing.T) { + path := filepath.Join("..", "..", "testdata", "log-format", "2026-07-05.json") original, err := os.ReadFile(path) if err != nil { - t.Fatalf("read legacy sample: %v", err) + t.Fatalf("read format sample: %v", err) } var body Log if err := json.Unmarshal(original, &body); err != nil { - t.Fatalf("unmarshal legacy sample: %v", err) + t.Fatalf("unmarshal format sample: %v", err) } if len(body.Items) != 3 { t.Fatalf("items = %d, want 3", len(body.Items)) } - if !body.Items[0].IsTask() || !body.Items[0].IsClosed() { - t.Errorf("item 0 should be a closed task: %+v", body.Items[0]) + if item := body.Items[0]; !item.IsTask() || !item.IsClosed() || !item.IsStarted() || len(item.Tags) != 2 { + t.Errorf("item 0 should be a closed, started task with 2 tags: %+v", item) } - if !body.Items[1].IsTask() || body.Items[1].IsClosed() { - t.Errorf("item 1 should be an open task: %+v", body.Items[1]) + if item := body.Items[1]; !item.IsTask() || item.IsClosed() || item.IsStarted() || item.Tags != nil { + t.Errorf("item 1 should be an open task without optional fields: %+v", item) } - if body.Items[2].IsTask() { - t.Errorf("item 2 should be a memo: %+v", body.Items[2]) + if item := body.Items[2]; item.IsTask() || !item.HasTag("shrimp") { + t.Errorf("item 2 should be a memo tagged shrimp: %+v", item) } remarshaled, err := json.MarshalIndent(body, "", " ") if err != nil { t.Fatalf("marshal: %v", err) } - if string(remarshaled) != string(original) { + if string(remarshaled) != strings.TrimRight(string(original), "\n") { t.Errorf("round trip mismatch:\n--- original ---\n%s\n--- remarshaled ---\n%s", original, remarshaled) } } +func TestHasTag(t *testing.T) { + item := Item{Tags: []string{"go", "cli"}} + if !item.HasTag("go") || item.HasTag("web") { + t.Errorf("HasTag mismatch: %+v", item.Tags) + } + if (Item{}).HasTag("go") { + t.Error("item without tags should not match") + } +} + func TestNewID(t *testing.T) { pattern := regexp.MustCompile(`^[0-9a-f]{8}$`) seen := map[string]bool{} diff --git a/internal/view/view.go b/internal/view/view.go index fd68abb..87fbfca 100644 --- a/internal/view/view.go +++ b/internal/view/view.go @@ -1,11 +1,10 @@ -// Package view renders command output. The layout follows the former -// Deno implementation; wording equivalence is functional, not -// character-exact. +// Package view renders command output. package view import ( "fmt" "io" + "strings" "time" "github.com/rn404/nippo-cli/internal/model" @@ -29,10 +28,13 @@ func ItemList(w io.Writer, tasks, memos []model.Item) { fmt.Fprintln(w, "Task ->") for _, item := range tasks { checkbox := "[ ]" - if item.IsClosed() { + switch { + case item.IsClosed(): checkbox = "[x]" + case item.IsStarted(): + checkbox = "[>]" } - fmt.Fprintf(w, "%s %s %s (%s) %s\n", bullet, checkbox, item.Content, formatTime(item.CreatedAt), item.Hash) + fmt.Fprintf(w, "%s %s %s (%s) %s%s\n", bullet, checkbox, item.Content, formatTime(item.CreatedAt), item.Hash, formatTags(item.Tags)) } } @@ -43,7 +45,7 @@ func ItemList(w io.Writer, tasks, memos []model.Item) { if len(memos) > 0 { fmt.Fprintln(w, "Memo ->") for _, item := range memos { - fmt.Fprintf(w, "%s %s (%s) %s\n", bullet, item.Content, formatTime(item.CreatedAt), item.Hash) + fmt.Fprintf(w, "%s %s (%s) %s%s\n", bullet, item.Content, formatTime(item.CreatedAt), item.Hash, formatTags(item.Tags)) } } } @@ -54,6 +56,28 @@ func FinishedTask(w io.Writer, item model.Item) { fmt.Fprintf(w, "> %s (%s)\n", item.Content, formatTime(item.CreatedAt)) } +// StartedTask prints the started task confirmation. +func StartedTask(w io.Writer, item model.Item) { + fmt.Fprintln(w, "Started!!") + fmt.Fprintf(w, "> %s (%s)\n", item.Content, formatTime(item.CreatedAt)) +} + +// TagsUpdated prints the item's tags after a tag change. +func TagsUpdated(w io.Writer, item model.Item) { + fmt.Fprintln(w, "Tags updated!!") + fmt.Fprintf(w, "> %s (%s)%s\n", item.Content, formatTime(item.CreatedAt), formatTags(item.Tags)) +} + +// Diff prints two items with full creation dates and the elapsed time +// between them. +func Diff(w io.Writer, a, b model.Item, elapsed time.Duration) { + fmt.Fprintln(w, "Diff...") + fmt.Fprintf(w, "> %s (%s) %s\n", a.Content, formatDateTime(a.CreatedAt), a.Hash) + fmt.Fprintf(w, "> %s (%s) %s\n", b.Content, formatDateTime(b.CreatedAt), b.Hash) + fmt.Fprintln(w) + fmt.Fprintf(w, "Elapsed: %s\n", formatDuration(elapsed)) +} + // FileStat prints a one-line summary of a daily log file. func FileStat(w io.Writer, name string, freezed bool, tasks, memos []model.Item, unfinished int) { freezedMark := " " @@ -69,6 +93,19 @@ func ListItem(w io.Writer, message string) { fmt.Fprintf(w, "%s %s\n", bullet, message) } +// formatTags renders tags as " #a #b", or "" when there are none. +func formatTags(tags []string) string { + if len(tags) == 0 { + return "" + } + var b strings.Builder + for _, tag := range tags { + b.WriteString(" #") + b.WriteString(tag) + } + return b.String() +} + // formatTime renders an ISO timestamp as local HH:mm. func formatTime(iso string) string { t, err := time.Parse(time.RFC3339, iso) @@ -77,3 +114,47 @@ func formatTime(iso string) string { } return t.Local().Format("15:04") } + +// formatDateTime renders an ISO timestamp as local yyyy-MM-dd HH:mm. +func formatDateTime(iso string) string { + t, err := time.Parse(time.RFC3339, iso) + if err != nil { + return iso + } + return t.Local().Format("2006-01-02 15:04") +} + +// formatDuration renders a duration as its non-zero components, e.g. +// "1d 2h 30m", "45m 10s" or "0s". +func formatDuration(d time.Duration) string { + d = d.Round(time.Second) + if d < 0 { + d = -d + } + + days := d / (24 * time.Hour) + d -= days * 24 * time.Hour + hours := d / time.Hour + d -= hours * time.Hour + minutes := d / time.Minute + seconds := (d - minutes*time.Minute) / time.Second + + var parts []string + for _, part := range []struct { + value int64 + unit string + }{ + {int64(days), "d"}, + {int64(hours), "h"}, + {int64(minutes), "m"}, + {int64(seconds), "s"}, + } { + if part.value > 0 { + parts = append(parts, fmt.Sprintf("%d%s", part.value, part.unit)) + } + } + if len(parts) == 0 { + return "0s" + } + return strings.Join(parts, " ") +} diff --git a/internal/view/view_test.go b/internal/view/view_test.go index f2476b2..7cb34d8 100644 --- a/internal/view/view_test.go +++ b/internal/view/view_test.go @@ -3,6 +3,7 @@ package view import ( "strings" "testing" + "time" "github.com/rn404/nippo-cli/internal/model" ) @@ -10,12 +11,14 @@ import ( func TestItemList(t *testing.T) { closed := true open := false + startedAt := "2026-07-05T09:00:00.000Z" tasks := []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"}, + {Hash: "cccc3333", Content: "shrimp looks happy today", CreatedAt: "2026-07-05T08:43:05.073Z", Tags: []string{"shrimp", "pet"}}, } var buf strings.Builder @@ -27,9 +30,10 @@ func TestItemList(t *testing.T) { "- [x] buy cabbage (", ") aaaa1111", "- [ ] feed the shrimp (", + "- [>] slice cabbage (", "Memo ->", "- shrimp looks happy today (", - ") cccc3333", + ") cccc3333 #shrimp #pet", } { if !strings.Contains(out, want) { t.Errorf("output should contain %q:\n%s", want, out) @@ -71,3 +75,68 @@ func TestFinishedTask(t *testing.T) { t.Errorf("FinishedTask output = %q", out) } } + +func TestStartedTask(t *testing.T) { + var buf strings.Builder + StartedTask(&buf, model.Item{Content: "buy cabbage", CreatedAt: "2026-07-05T08:43:04.971Z"}) + out := buf.String() + if !strings.Contains(out, "Started!!") || !strings.Contains(out, "> buy cabbage (") { + t.Errorf("StartedTask output = %q", out) + } +} + +func TestDiff(t *testing.T) { + a := model.Item{Hash: "aaaa1111", Content: "buy cabbage", CreatedAt: "2026-07-05T10:00:00.000Z"} + b := model.Item{Hash: "bbbb2222", Content: "feed the shrimp", CreatedAt: "2026-07-06T12:30:00.000Z"} + + var buf strings.Builder + Diff(&buf, a, b, 26*time.Hour+30*time.Minute) + out := buf.String() + + for _, want := range []string{ + "Diff...", + "> buy cabbage (2026-07-05", + ") aaaa1111", + "> feed the shrimp (2026-07-06", + ") bbbb2222", + "Elapsed: 1d 2h 30m", + } { + if !strings.Contains(out, want) { + t.Errorf("output should contain %q:\n%s", want, out) + } + } +} + +func TestFormatDuration(t *testing.T) { + cases := map[time.Duration]string{ + 0: "0s", + 45 * time.Second: "45s", + time.Minute + 10*time.Second: "1m 10s", + 2 * time.Hour: "2h", + 26*time.Hour + 30*time.Minute: "1d 2h 30m", + -(time.Hour + time.Second): "1h 1s", + 500 * time.Millisecond: "1s", // rounded + 24*time.Hour + 5*time.Minute: "1d 5m", + 48*time.Hour + 59*time.Second: "2d 59s", + } + for d, want := range cases { + if got := formatDuration(d); got != want { + t.Errorf("formatDuration(%v) = %q, want %q", d, got, want) + } + } +} + +func TestTagsUpdated(t *testing.T) { + var buf strings.Builder + TagsUpdated(&buf, model.Item{Content: "buy cabbage", CreatedAt: "2026-07-05T08:43:04.971Z", Tags: []string{"cabbage"}}) + out := buf.String() + if !strings.Contains(out, "Tags updated!!") || !strings.Contains(out, "#cabbage") { + t.Errorf("TagsUpdated output = %q", out) + } + + buf.Reset() + TagsUpdated(&buf, model.Item{Content: "buy cabbage", CreatedAt: "2026-07-05T08:43:04.971Z"}) + if strings.Contains(buf.String(), "#") { + t.Errorf("item without tags should print no tag marks: %q", buf.String()) + } +} diff --git a/testdata/legacy-samples/2026-07-05.json b/testdata/log-format/2026-07-05.json similarity index 58% rename from testdata/legacy-samples/2026-07-05.json rename to testdata/log-format/2026-07-05.json index f751d72..009185b 100644 --- a/testdata/legacy-samples/2026-07-05.json +++ b/testdata/log-format/2026-07-05.json @@ -1,26 +1,34 @@ { - "hash": "[object Object]", + "hash": "1c12ea34", "freezed": false, "items": [ { - "hash": "[object Object]", + "hash": "3097180c", "content": "buy cabbage", "createdAt": "2026-07-05T08:43:04.971Z", "updatedAt": "2026-07-05T08:44:12.345Z", - "closed": true + "startedAt": "2026-07-05T08:43:30.000Z", + "closed": true, + "tags": [ + "cabbage", + "shopping" + ] }, { - "hash": "[object Object]", + "hash": "e2e59dfe", "content": "feed the shrimp", "createdAt": "2026-07-05T08:43:05.026Z", "updatedAt": "2026-07-05T08:43:05.026Z", "closed": false }, { - "hash": "[object Object]", + "hash": "cccc3333", "content": "shrimp looks happy today", "createdAt": "2026-07-05T08:43:05.073Z", - "updatedAt": "2026-07-05T08:43:05.073Z" + "updatedAt": "2026-07-05T08:43:05.073Z", + "tags": [ + "shrimp" + ] } ] -} \ No newline at end of file +}