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
37 changes: 23 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,33 +37,38 @@ go install github.com/rn404/nippo-cli/cmd/sava@latest
## Usage

```
# Add todo item
# Add a memo (default)
sava add <message>

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

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

# Start todo item
# Start an existing TODO item
sava start <hash>

# Finish todo item
sava end <hash>
# Finish one or more TODO items
sava end <hash>...

# Delete item
# Delete item (searches the last 30 days by default)
sava del <hash>

# Add item with tags / manage tags afterwards
# Delete a specific day's item directly, or search every log ever
sava del <date>:<hash>
sava del --deep <hash>

# Add item with tags (memo or TODO) / manage tags afterwards
sava add -t <tag>[,<tag>...] <message>
sava todo -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>
# Show elapsed time between two items (each given as <date>:<hash>)
sava diff <date>:<hashA>...<date>:<hashB>
sava diff <date>:<hashA> <date>:<hashB>

# List today's log items
sava list
Expand All @@ -88,9 +93,13 @@ sava clear -a
ログは `~/.log/sava/<yyyy-MM-dd>.json` に 1 日 1 ファイルで保存されます.
フォーマットの仕様サンプルは `testdata/log-format/` にあります.

タグ操作時には `~/.log/sava/index.json` (タグ・hash から日付ファイルへの逆引きキャッシュ)
タグ操作時には `~/.log/sava/index.json` (タグから日付ファイルへの逆引きキャッシュ)
が再生成されます. 壊れても全ログから再構築できるキャッシュです.

`sava diff` と `sava del --deep`(あるいは30日より前を含む検索)は、hash から
日付を逆引きするのではなく `<date>:<hash>` を要求 / 全ログを直接走査します.
これは hash の一意性が保証されるのは同一日のログ内だけであるためです.

### Objects
* LogFile > Log > Item (Task, Memo)

Expand Down
95 changes: 57 additions & 38 deletions cmd/sava/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,57 @@ func newAddCommand() *cobra.Command {
opts := command.AddOptions{}
cmd := &cobra.Command{
Use: "add <contents>",
Short: "Add contents to nippo log.",
Short: "Add a memo to nippo log.",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
return command.Add(logfile.Dir(), args[0], opts)
RunE: func(cmd *cobra.Command, args []string) error {
return command.Add(cmd.OutOrStdout(), logfile.Dir(), args[0], opts)
},
}
cmd.Flags().StringSliceVarP(&opts.Tags, "tag", "t", nil, "put tags on the new item")
return cmd
}

// newTodoCommand and newAddCommand must never gain subcommands: cobra
// resolves a matching child command name before falling back to
// <contents>, so a subcommand named e.g. "start" would make it
// impossible to create an item whose content is literally "start".
func newTodoCommand() *cobra.Command {
opts := command.TodoOptions{}
cmd := &cobra.Command{
Use: "todo <contents>",
Short: "Add a TODO item to nippo log.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return command.Todo(cmd.OutOrStdout(), 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 newStartCommand() *cobra.Command {
return &cobra.Command{
Use: "start <hash>",
Short: "start an existing TODO item.",
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>...",
Short: "finish one or more TODO items.",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return command.End(cmd.OutOrStdout(), logfile.Dir(), args)
},
}
}

func newTagCommand() *cobra.Command {
var remove, list bool
cmd := &cobra.Command{
Expand All @@ -51,56 +89,37 @@ func newTagCommand() *cobra.Command {
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>",
Short: "end to task.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return command.End(cmd.OutOrStdout(), logfile.Dir(), args[0])
},
}
}

func newDelCommand() *cobra.Command {
return &cobra.Command{
Use: "del <hash>",
Short: "delete task.",
var deep bool
cmd := &cobra.Command{
Use: "del <hash>|<date>:<hash>",
Short: "delete item.",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
return command.Del(logfile.Dir(), args[0])
RunE: func(cmd *cobra.Command, args []string) error {
return command.Del(cmd.OutOrStdout(), logfile.Dir(), args[0], deep)
},
}
cmd.Flags().BoolVar(&deep, "deep", false, "search all logs instead of just the last 30 days")
return cmd
}

func newDiffCommand() *cobra.Command {
return &cobra.Command{
Use: "diff <hashA>...<hashB>",
Use: "diff <date>:<hashA>...<date>:<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)
refA, refB, err := splitDiffArgs(args)
if err != nil {
return err
}
return command.Diff(cmd.OutOrStdout(), logfile.Dir(), hashA, hashB)
return command.Diff(cmd.OutOrStdout(), logfile.Dir(), refA, refB)
},
}
}

// splitDiffArgs accepts either "<hashA>...<hashB>" (also "..") as one
// argument or two separate hash arguments.
// splitDiffArgs accepts either "<refA>...<refB>" (also "..") as one
// argument or two separate ref arguments.
func splitDiffArgs(args []string) (string, string, error) {
if len(args) == 2 {
return args[0], args[1], nil
Expand All @@ -111,7 +130,7 @@ func splitDiffArgs(args []string) (string, string, error) {
return parts[0], parts[1], nil
}
}
return "", "", fmt.Errorf("expected <hashA>...<hashB> or two hashes")
return "", "", fmt.Errorf("expected <refA>...<refB> or two refs")
}

func newListCommand() *cobra.Command {
Expand Down
1 change: 1 addition & 0 deletions cmd/sava/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ func newRootCommand() *cobra.Command {

root.AddCommand(
newAddCommand(),
newTodoCommand(),
newStartCommand(),
newEndCommand(),
newDelCommand(),
Expand Down
82 changes: 68 additions & 14 deletions cmd/sava/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package main
import (
"strings"
"testing"

"github.com/rn404/nippo-cli/internal/model"
)

// execute runs the root command with args and returns combined output.
Expand Down Expand Up @@ -42,8 +44,8 @@ func TestVersion(t *testing.T) {
func TestAddListFlow(t *testing.T) {
t.Setenv("HOME", t.TempDir())

mustExecute(t, "add", "buy cabbage")
mustExecute(t, "add", "-m", "shrimp memo")
mustExecute(t, "todo", "buy cabbage")
mustExecute(t, "add", "shrimp memo")

out := mustExecute(t, "list")
for _, want := range []string{"Task ->", "buy cabbage", "Memo ->", "shrimp memo"} {
Expand All @@ -58,25 +60,72 @@ func TestAddListFlow(t *testing.T) {
}
}

func TestAddOutputsHash(t *testing.T) {
t.Setenv("HOME", t.TempDir())

out := mustExecute(t, "add", "buy cabbage")
if !strings.Contains(out, "Added!!") {
t.Errorf("add output should confirm the addition:\n%s", out)
}
}

func TestStartFlow(t *testing.T) {
t.Setenv("HOME", t.TempDir())

mustExecute(t, "add", "-s", "slice cabbage")
mustExecute(t, "todo", "-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")
t.Errorf("todo added with -s should be shown as started:\n%s", out)
}

if _, err := execute(t, "start", "no-such-hash"); err == nil {
t.Error("start with an unknown hash should fail")
}
}

func TestEndMultipleHashes(t *testing.T) {
t.Setenv("HOME", t.TempDir())

mustExecute(t, "todo", "first task")
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])
}
}
if len(hashes) != 2 {
t.Fatalf("hashes = %+v, want 2:\n%s", hashes, list)
}

out := mustExecute(t, "end", hashes[0], hashes[1])
if got := strings.Count(out, "Finished!!"); got != 2 {
t.Errorf("Finished!! count = %d, want 2:\n%s", got, out)
}

if _, err := execute(t, "end", "no-such-hash"); err == nil {
t.Error("end with an unknown hash should fail")
}
}

// TestTodoContentCanBeStartOrEnd guards against regressing to nesting
// start/end as todo subcommands, which made it impossible to create a
// TODO whose entire content is literally "start" or "end".
func TestTodoContentCanBeStartOrEnd(t *testing.T) {
t.Setenv("HOME", t.TempDir())

for _, content := range []string{"start", "end"} {
out := mustExecute(t, "todo", content)
if !strings.Contains(out, "Added!!") {
t.Errorf("todo %q should create an item, not dispatch to a subcommand:\n%s", content, out)
}
}
}

func TestTagFlow(t *testing.T) {
t.Setenv("HOME", t.TempDir())

Expand Down Expand Up @@ -105,8 +154,8 @@ func TestTagFlow(t *testing.T) {
func TestDiffFlow(t *testing.T) {
t.Setenv("HOME", t.TempDir())

mustExecute(t, "add", "first task")
mustExecute(t, "add", "second task")
mustExecute(t, "todo", "first task")
mustExecute(t, "todo", "second task")

list := mustExecute(t, "list")
var hashes []string
Expand All @@ -119,22 +168,27 @@ func TestDiffFlow(t *testing.T) {
if len(hashes) != 2 {
t.Fatalf("hashes = %+v, want 2:\n%s", hashes, list)
}
today := model.Today()
refA, refB := today+":"+hashes[0], today+":"+hashes[1]

out := mustExecute(t, "diff", hashes[0]+"..."+hashes[1])
out := mustExecute(t, "diff", refA+"..."+refB)
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])
out = mustExecute(t, "diff", refA, refB)
if !strings.Contains(out, "Elapsed: ") {
t.Errorf("two-arg diff output:\n%s", out)
}

if _, err := execute(t, "diff", "lonely-hash"); err == nil {
if _, err := execute(t, "diff", "lonely-ref"); err == nil {
t.Error("diff without a separator should fail")
}
if _, err := execute(t, "diff", hashes[0]+"...no-such-hash"); err == nil {
if _, err := execute(t, "diff", hashes[0], hashes[1]); err == nil {
t.Error("diff with bare hashes (no date) should fail")
}
if _, err := execute(t, "diff", refA+"..."+today+":no-such-hash"); err == nil {
t.Error("diff with an unknown hash should fail")
}
}
Expand Down
Loading
Loading