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
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,38 @@ go install github.com/rn404/nippo-cli/cmd/sava@latest
# Add todo item
sava add <message>

# Add todo item and start it right away
sava add -s <message>

# Add memo item
sava add -m <message>

# Start todo item
sava start <hash>

# Finish todo item
sava end <hash>

# Delete item
sava del <hash>

# Add item with tags / manage tags afterwards
sava add -t <tag>[,<tag>...] <message>
sava tag <hash> <tag>...
sava tag -d <hash> <tag>...
sava tag --list

# Show elapsed time between two items (resolved across days)
sava diff <hashA>...<hashB>
sava diff <hashA> <hashB>

# List today's log items
sava list

# Filter by tags (multiple tags match all; --or matches any)
sava list -t <tag>[,<tag>...]
sava list -t <tag>,<tag> --or

# List items of a specific day / all log files / summaries
sava list <yyyy-MM-dd>
sava list -a
Expand All @@ -66,12 +86,16 @@ sava clear -a
```

ログは `~/.log/sava/<yyyy-MM-dd>.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

Expand Down
79 changes: 76 additions & 3 deletions cmd/sava/commands.go
Original file line number Diff line number Diff line change
@@ -1,26 +1,67 @@
package main

import (
"fmt"
"strings"

"github.com/spf13/cobra"

"github.com/rn404/nippo-cli/internal/command"
"github.com/rn404/nippo-cli/internal/logfile"
)

func newAddCommand() *cobra.Command {
var memo bool
opts := command.AddOptions{}
cmd := &cobra.Command{
Use: "add <contents>",
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 <hash> <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 <hash> and at least one <tag>")
}
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 <hash>",
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 <hash>",
Expand All @@ -43,6 +84,36 @@ func newDelCommand() *cobra.Command {
}
}

func newDiffCommand() *cobra.Command {
return &cobra.Command{
Use: "diff <hashA>...<hashB>",
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 "<hashA>...<hashB>" (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 <hashA>...<hashB> or two hashes")
}

func newListCommand() *cobra.Command {
opts := command.ListOptions{}
cmd := &cobra.Command{
Expand All @@ -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
}

Expand Down
3 changes: 3 additions & 0 deletions cmd/sava/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ func newRootCommand() *cobra.Command {

root.AddCommand(
newAddCommand(),
newStartCommand(),
newEndCommand(),
newDelCommand(),
newTagCommand(),
newDiffCommand(),
newListCommand(),
newClearCommand(),
)
Expand Down
81 changes: 81 additions & 0 deletions cmd/sava/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
67 changes: 66 additions & 1 deletion docs/update-command-and-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,69 @@ MemoItem への tag づけ機能
* 複数の tag を自由につけることができるようにし、アイテムのフィルタリングなどもできるようにする
* 存在する tag は後で管理するのが大変になるので、tagづけするコマンドが走ったらインデックス相当のファイルを吐き出しておくといいかも
* 複数タグ付けができる
* list時には曖昧検索、AND, OR検索をサポートしたい
* 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 <contents> # タスク追加
sava add <contents> -s # タスク追加 + 即着手
sava add <contents> -m # メモ追加
sava add <contents> -t <tag> # タグ付きで追加(-t 複数指定可)
sava start <hash> # タスク着手(startedAt を記録)
sava end <hash> # タスク完了
sava del <hash> # アイテム削除
sava tag <hash> <tag>... # タグ付与(インデックス更新)
sava tag -d <hash> <tag>... # タグ除去
sava tag --list # 既存タグ一覧
sava list [date] [-t <tag>] # タグフィルタ(複数は AND、--or で OR)
sava diff <hashA>...<hashB> # 2アイテム間の経過時間
sava clear [-a] # 既存のまま
```

### インデックスファイル
* `~/.log/sava/index.json` に `tag → [{date, hash}]` と `hash → date` の逆引きを保持
* あくまでキャッシュ扱い: 壊れたら全ログから再構築できる
* `list` のタグフィルタと `diff` の日またぎ hash 解決の両方が乗る

### diff の仕様(Phase 3 で確定)
* `sava diff <hashA>...<hashB>`(`..` 区切り、ハッシュ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 があるため不要と判断
Loading
Loading