diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index fed310b..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: ['**'] - -jobs: - affected-checks: - runs-on: ubuntu-latest - outputs: - matches: ${{ steps.filter.outputs['src'] || 'false' }} - steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v2 - id: filter - with: - filters: | - src: - - 'src/**' - - 'test/**' - - '!.gitignore' - - '!.github/**' - - '!.vscode/**' - - '!docs/**' - - '!**/*.md' - - guard: - needs: affected-checks - if: ${{ needs.affected-checks.outputs.matches == 'true' }} - uses: ./.github/workflows/wc_ci.yml diff --git a/.github/workflows/go_ci.yml b/.github/workflows/go_ci.yml new file mode 100644 index 0000000..6daad17 --- /dev/null +++ b/.github/workflows/go_ci.yml @@ -0,0 +1,48 @@ +name: Go CI + +on: + push: + branches: [main, migrate] + pull_request: + branches: ['**'] + +permissions: + contents: read + +jobs: + affected-checks: + runs-on: ubuntu-latest + outputs: + matches: ${{ steps.filter.outputs['go'] || 'false' }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v2 + id: filter + with: + filters: | + go: + - '**/*.go' + - 'go.mod' + - 'go.sum' + - '.github/workflows/go_ci.yml' + + guard: + needs: affected-checks + if: ${{ needs.affected-checks.outputs.matches == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Check formatting + run: test -z "$(gofmt -l .)" + - name: Vet + run: go vet ./... + - name: Test + run: go test ./... + - name: Lint + uses: golangci/golangci-lint-action@v8 + with: + # Keep in sync with .mise.toml for local reproducibility. + version: v2.12.2 diff --git a/.github/workflows/wc_ci.yml b/.github/workflows/wc_ci.yml deleted file mode 100644 index c76604b..0000000 --- a/.github/workflows/wc_ci.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: CI (on workflow call) - -on: - workflow_call: - -jobs: - source-guard: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Deno - uses: denoland/setup-deno@v1 - with: - deno-version: 2.4.3 - - - name: Check source code quality - run: deno task source-guard - - runtime-guard: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Deno - uses: denoland/setup-deno@v1 - with: - deno-version: 2.4.3 - - - name: Run Tests - run: deno task runtime-guard \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9cefc36..3d741e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,9 @@ .log # Testing dummy files -test/dummy_log_dir/* \ No newline at end of file +test/dummy_log_dir/* +# Obsidian editor config +docs/.obsidian/ + +# Go build artifacts +/sava diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..0ba2457 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,10 @@ +version: "2" + +linters: + settings: + errcheck: + # CLI output to stdout/stderr: write errors are not actionable. + exclude-functions: + - fmt.Fprint + - fmt.Fprintf + - fmt.Fprintln diff --git a/.mise.toml b/.mise.toml index 1c117b9..bd5ffbd 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,2 +1,4 @@ [tools] -deno = "2.4.3" +go = "1.26.4" +# Keep in sync with .github/workflows/go_ci.yml +golangci-lint = "2.12.2" diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index b38d306..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [{ - "name": "Deno", - "type": "node", - "request": "launch", - "cwd": "${workspaceFolder}", - "runtimeExecutable": "deno", - "runtimeArgs": ["run", "--inspect-brk", "-A", "${file}"], - "port": 9229, - "outputCapture": "std" - }] -} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 566e82e..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "deno.enable": true, -} diff --git a/README.md b/README.md index 6b70107..f04cfb1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +# nippo-cli (`sava`) + ## Motivation / Background エンジニアはほとんどの時間を console をみて過ごしている. 作業時間やちょっとした思考をメモしておくのに他ツールとのスイッチはコストが高いと考えます. @@ -8,48 +10,68 @@ - cli ツール作りたい - 独り言メモツールしたい(日報補助ツール) -## command -``` -sava add 'message' -``` - 構想は gist に https://gist.github.com/rn404/decf010fc48d7d8688116af0f4427b44 -### Objects -* LogFile > Log > Item (MemoItem, TaskItem) +## Install -### Architecture -* Command -- Feature -- LogFile -* Feature -- LogFile - * LogFileInterface -* Command -- Feature - * Models(Class instance) +``` +go install github.com/rn404/nippo-cli/cmd/sava@latest +``` +## Usage -## Usage (developer) ``` -# command help -deno run src/sava.ts - # Add todo item -deno run -A src/sava.ts add - -# Finish todo item -deno run -A src/sava.ts end +sava add # Add memo item -deno run -A src/sava.ts add -m +sava add -m + +# Finish todo item +sava end # Delete item -deno run -A src/sava.ts del +sava del + +# List today's log items +sava list + +# List items of a specific day / all log files / summaries +sava list +sava list -a +sava list -s +sava list -a -s -# List log items -deno run -A src/sava.ts list +# Delete logs past the storage period (30 days) +sava clear + +# Delete all logs (with confirmation; use -y to skip prompts) +sava clear -a ``` -### Formatter +ログは `~/.log/sava/.json` に 1 日 1 ファイルで保存されます. + +### Objects +* LogFile > Log > Item (Task, Memo) + +### Architecture +* cmd/sava -- internal/command -- internal/{logfile, log, view} -- internal/model + +## Development ``` -./scripts/format.sh +# Run from source +go run ./cmd/sava + +# Test / format / lint +go test ./... +gofmt -l . +go vet ./... +golangci-lint run # version is pinned in .mise.toml (mise install) ``` + +## History + +もともと Deno / TypeScript で実装されていましたが、Go に移行しました. +経緯は `docs/go-migration-plan.md` を参照してください. diff --git a/cmd/sava/commands.go b/cmd/sava/commands.go new file mode 100644 index 0000000..1b30baa --- /dev/null +++ b/cmd/sava/commands.go @@ -0,0 +1,78 @@ +package main + +import ( + "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 + cmd := &cobra.Command{ + Use: "add ", + Short: "Add contents to nippo log.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return command.Add(logfile.Dir(), args[0], memo) + }, + } + cmd.Flags().BoolVarP(&memo, "memo", "m", false, "Add contents like memo item.") + return cmd +} + +func newEndCommand() *cobra.Command { + return &cobra.Command{ + Use: "end ", + 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 ", + Short: "delete task.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return command.Del(logfile.Dir(), args[0]) + }, + } +} + +func newListCommand() *cobra.Command { + opts := command.ListOptions{} + cmd := &cobra.Command{ + Use: "list [date]", + Short: "list all logs.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 1 { + opts.Date = args[0] + } + return command.List(cmd.OutOrStdout(), cmd.InOrStdin(), logfile.Dir(), opts) + }, + } + 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") + return cmd +} + +func newClearCommand() *cobra.Command { + var all, yes bool + cmd := &cobra.Command{ + Use: "clear", + Short: "delete log", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return command.Clear(cmd.OutOrStdout(), cmd.InOrStdin(), logfile.Dir(), all, yes) + }, + } + cmd.Flags().BoolVarP(&all, "all", "a", false, "clear all logs") + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation prompts") + return cmd +} diff --git a/cmd/sava/main.go b/cmd/sava/main.go new file mode 100644 index 0000000..d9e23a3 --- /dev/null +++ b/cmd/sava/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +const appName = "sava" + +// version is overridable at build time: +// go build -ldflags "-X main.version=vX.Y.Z" +var version = "v0.1.0" + +func main() { + root := newRootCommand() + if err := root.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "%s: %v\n", appName, err) + os.Exit(1) + } +} + +func newRootCommand() *cobra.Command { + root := &cobra.Command{ + Use: appName, + Short: "A memo tool to support daily reports", + Version: version, + SilenceUsage: true, + SilenceErrors: true, + } + + root.AddCommand( + newAddCommand(), + newEndCommand(), + newDelCommand(), + newListCommand(), + newClearCommand(), + ) + + return root +} diff --git a/deno.json b/deno.json deleted file mode 100644 index b7f88fe..0000000 --- a/deno.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "imports": { - "@std/path": "jsr:@std/path@^1.1.0", - "@std/fs": "jsr:@std/fs@^1.0.0", - "@std/assert": "jsr:@std/assert@^1.0.13", - "@std/testing": "jsr:@std/testing@^1.0.0", - "cliffy": "https://deno.land/x/cliffy@v0.20.1/command/mod.ts" - }, - "tasks": { - "run": "deno run --allow-read --allow-write --allow-env src/sava.ts", - "update-lockfile": "deno install --entrypoint ./src/sava.ts", - "precommit": "deno fmt && deno lint --fix && deno test --allow-read --allow-write --allow-env", - "source-guard": "deno fmt --check && deno lint && deno check ./src", - "runtime-guard": "deno test --allow-read --allow-write --allow-env" - }, - "compilerOptions": { - "lib": ["deno.window"], - "strict": true - }, - "lint": { - "include": ["src/"], - "rules": { - "tags": ["recommended"], - "include": ["ban-untagged-todo"], - "exclude": ["no-unused-vars"] - } - }, - "fmt": { - "include": ["src/", "test/"], - "useTabs": false, - "lineWidth": 80, - "indentWidth": 2, - "singleQuote": true, - "proseWrap": "preserve" - } -} \ No newline at end of file diff --git a/deno.lock b/deno.lock deleted file mode 100644 index 390ad98..0000000 --- a/deno.lock +++ /dev/null @@ -1,211 +0,0 @@ -{ - "version": "5", - "specifiers": { - "jsr:@std/assert@^1.0.13": "1.0.14", - "jsr:@std/data-structures@^1.0.9": "1.0.9", - "jsr:@std/fs@1": "1.0.19", - "jsr:@std/fs@^1.0.19": "1.0.19", - "jsr:@std/internal@^1.0.10": "1.0.10", - "jsr:@std/internal@^1.0.9": "1.0.10", - "jsr:@std/path@^1.1.0": "1.1.2", - "jsr:@std/path@^1.1.1": "1.1.2", - "jsr:@std/testing@1": "1.0.15", - "npm:@types/node@*": "22.15.15" - }, - "jsr": { - "@std/assert@1.0.14": { - "integrity": "68d0d4a43b365abc927f45a9b85c639ea18a9fab96ad92281e493e4ed84abaa4", - "dependencies": [ - "jsr:@std/internal@^1.0.10" - ] - }, - "@std/data-structures@1.0.9": { - "integrity": "033d6e17e64bf1f84a614e647c1b015fa2576ae3312305821e1a4cb20674bb4d" - }, - "@std/fs@1.0.19": { - "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06", - "dependencies": [ - "jsr:@std/internal@^1.0.9", - "jsr:@std/path@^1.1.1" - ] - }, - "@std/internal@1.0.10": { - "integrity": "e3be62ce42cab0e177c27698e5d9800122f67b766a0bea6ca4867886cbde8cf7" - }, - "@std/path@1.1.2": { - "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038", - "dependencies": [ - "jsr:@std/internal@^1.0.10" - ] - }, - "@std/testing@1.0.15": { - "integrity": "a490169f5ccb0f3ae9c94fbc69d2cd43603f2cffb41713a85f99bbb0e3087cbc", - "dependencies": [ - "jsr:@std/assert", - "jsr:@std/data-structures", - "jsr:@std/fs@^1.0.19", - "jsr:@std/internal@^1.0.10", - "jsr:@std/path@^1.1.1" - ] - } - }, - "npm": { - "@types/node@22.15.15": { - "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", - "dependencies": [ - "undici-types" - ] - }, - "undici-types@6.21.0": { - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" - } - }, - "remote": { - "https://deno.land/std@0.113.0/fmt/colors.ts": "8368ddf2d48dfe413ffd04cdbb7ae6a1009cf0dccc9c7ff1d76259d9c61a0621", - "https://deno.land/std@0.170.0/_util/asserts.ts": "d0844e9b62510f89ce1f9878b046f6a57bf88f208a10304aab50efcb48365272", - "https://deno.land/std@0.170.0/_util/os.ts": "8a33345f74990e627b9dfe2de9b040004b08ea5146c7c9e8fe9a29070d193934", - "https://deno.land/std@0.170.0/encoding/base64.ts": "8605e018e49211efc767686f6f687827d7f5fd5217163e981d8d693105640d7a", - "https://deno.land/std@0.170.0/fmt/colors.ts": "03ad95e543d2808bc43c17a3dd29d25b43d0f16287fe562a0be89bf632454a12", - "https://deno.land/std@0.170.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3", - "https://deno.land/std@0.170.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09", - "https://deno.land/std@0.170.0/path/_util.ts": "d16be2a16e1204b65f9d0dfc54a9bc472cafe5f4a190b3c8471ec2016ccd1677", - "https://deno.land/std@0.170.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633", - "https://deno.land/std@0.170.0/path/glob.ts": "81cc6c72be002cd546c7a22d1f263f82f63f37fe0035d9726aa96fc8f6e4afa1", - "https://deno.land/std@0.170.0/path/mod.ts": "cf7cec7ac11b7048bb66af8ae03513e66595c279c65cfa12bfc07d9599608b78", - "https://deno.land/std@0.170.0/path/posix.ts": "b859684bc4d80edfd4cad0a82371b50c716330bed51143d6dcdbe59e6278b30c", - "https://deno.land/std@0.170.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", - "https://deno.land/std@0.170.0/path/win32.ts": "7cebd2bda6657371adc00061a1d23fdd87bcdf64b4843bb148b0b24c11b40f69", - "https://deno.land/x/cliffy@v0.20.1/_utils/distance.ts": "02af166952c7c358ac83beae397aa2fbca4ad630aecfcd38d92edb1ea429f004", - "https://deno.land/x/cliffy@v0.20.1/command/_errors.ts": "979a5d3f91b9da60a5871a14c4d7aa88ad4e3258da2df7b83bf40d00949ca944", - "https://deno.land/x/cliffy@v0.20.1/command/_utils.ts": "1f56505da613df55b00a7f954b42c38b2bf0c5c0f13c1470d18e0a0fbd8c3619", - "https://deno.land/x/cliffy@v0.20.1/command/command.ts": "bef9ccf0267d928c844b1c98b565ee80d7f717d8d9fe86f205834c420ab7cb31", - "https://deno.land/x/cliffy@v0.20.1/command/completions/_bash_completions_generator.ts": "08f18bb5f701e0e8dc9f0a4ae955100d6e693586d6ef767c19debc884373d176", - "https://deno.land/x/cliffy@v0.20.1/command/completions/_fish_completions_generator.ts": "816eb56ee9c1db76ceda63e0a02eecce801d5a703cae814e67249b3648bdd5f7", - "https://deno.land/x/cliffy@v0.20.1/command/completions/_zsh_completions_generator.ts": "34f959c03eb6536c87bb29604bcd1c3e9dea04eff7f382594b7496af442f1ec6", - "https://deno.land/x/cliffy@v0.20.1/command/completions/bash.ts": "c989ce1eb3f92f51974b530a3985689eeff1dccfc0d763c20c66f38f12acff92", - "https://deno.land/x/cliffy@v0.20.1/command/completions/complete.ts": "c360de280a2d68415ec8d59e678fe8df4a19b910ebafc1a486728a42c340344d", - "https://deno.land/x/cliffy@v0.20.1/command/completions/fish.ts": "937eacc416282f406f5d81e9911262daa88302bbc83caf29f9ea096eca527d64", - "https://deno.land/x/cliffy@v0.20.1/command/completions/mod.ts": "96d199716fedcba3f47638ce569761e06ae10851cd0a872be3817ca135c84f58", - "https://deno.land/x/cliffy@v0.20.1/command/completions/zsh.ts": "807774a6ecfa3b544a29ef6087ca02c3b9be554529ce45f4826b357b0c27afd6", - "https://deno.land/x/cliffy@v0.20.1/command/deps.ts": "3d16782b5fdcf43f353e4d690f851d7008604d568cef07ec0b61527e47523a1d", - "https://deno.land/x/cliffy@v0.20.1/command/help/_help_generator.ts": "1fca9fd70b5583fc9d7855a6cd2e573427990cc18004c106b01864cdc815cfd7", - "https://deno.land/x/cliffy@v0.20.1/command/help/mod.ts": "fd4370261e62a9a201c52c12039870afaa501d849af9e04bba34b27a4cc5e2aa", - "https://deno.land/x/cliffy@v0.20.1/command/mod.ts": "ab5a5d05ad3e723bcb8434003847ff06dafbd43c58a86f5f47b69d4f6a10c79e", - "https://deno.land/x/cliffy@v0.20.1/command/type.ts": "d3d9b87de039f2322a4ac28a8a6ddc7c945513ed64ade387f9fe677d0fe247dd", - "https://deno.land/x/cliffy@v0.20.1/command/types.ts": "15e9ba88fd8735ca0897f649ae1ec67883989a41d6f0f1dccfb0fb839c7d96cd", - "https://deno.land/x/cliffy@v0.20.1/command/types/action_list.ts": "33c98d449617c7a563a535c9ceb3741bde9f6363353fd492f90a74570c611c27", - "https://deno.land/x/cliffy@v0.20.1/command/types/boolean.ts": "bf0fbaacbd79398f4f4a832437a8ac49f34f12fe42b8b44dca68ad23cd7b02e8", - "https://deno.land/x/cliffy@v0.20.1/command/types/child_command.ts": "f1fca390c7fbfa7a713ca15ef55c2c7656bcbb394d50e8ef54085bdf6dc22559", - "https://deno.land/x/cliffy@v0.20.1/command/types/command.ts": "325d0382e383b725fd8d0ef34ebaeae082c5b76a1f6f2e843fee5dbb1a4fe3ac", - "https://deno.land/x/cliffy@v0.20.1/command/types/enum.ts": "e54b362848416dd60f45824b50b01513db8e78bba1ede38d6344334c8f879b3f", - "https://deno.land/x/cliffy@v0.20.1/command/types/integer.ts": "b49ef8c43fe59ccf67e792c72ff350520dab08c9bd552eff004e134f292daa06", - "https://deno.land/x/cliffy@v0.20.1/command/types/number.ts": "32c3d1decf3b8d9dfa10b0027b07e9c6753c0059d1ccf2d1d768b879d05e0d3e", - "https://deno.land/x/cliffy@v0.20.1/command/types/string.ts": "e415dd7bce1b4d0ac4417e370a41a8c7a476f78cb19bf4a757ba11da343f344c", - "https://deno.land/x/cliffy@v0.20.1/flags/_errors.ts": "fa4a46b85051dd18683f6aba145b96e422d47a17744fc02db8f169915ca725c1", - "https://deno.land/x/cliffy@v0.20.1/flags/_utils.ts": "fe103ea0e0300dfacdd8627667e0731222d354805413a8a781f3252e4bff8baa", - "https://deno.land/x/cliffy@v0.20.1/flags/flags.ts": "db1691d4c0e14c8fee3c4e58c3a8c3d594bd3e50c6d69096a03c2677c477c49a", - "https://deno.land/x/cliffy@v0.20.1/flags/types.ts": "c69d7227c825e9c2f4be5a9b804e4275dfc9ebf855925fd8d831fed51e51c41a", - "https://deno.land/x/cliffy@v0.20.1/flags/types/boolean.ts": "3a80c2332c2f2f58427774cd0ed15b773be2c6bab6323ab4085ac7eaed9fe021", - "https://deno.land/x/cliffy@v0.20.1/flags/types/integer.ts": "931b6c9f748a839ad304d60851159ebe3f26ccea50fb0907925a94e93772acaf", - "https://deno.land/x/cliffy@v0.20.1/flags/types/number.ts": "ace1b124c644f4fac2a7bf781fafe3f8a32528b22ec939a3291424a313c8e301", - "https://deno.land/x/cliffy@v0.20.1/flags/types/string.ts": "2764413b7f86962152fd0ac3edf5107b94cb6ae8a237243d65c3a2eb2073d624", - "https://deno.land/x/cliffy@v0.20.1/flags/validate_flags.ts": "7724a6118a05e2b5b058451dc1be401615bbef5deb75d52f7e1b35b124bdb50c", - "https://deno.land/x/cliffy@v0.20.1/table/border.ts": "2514abae4e4f51eda60a5f8c927ba24efd464a590027e900926b38f68e01253c", - "https://deno.land/x/cliffy@v0.20.1/table/cell.ts": "1d787d8006ac8302020d18ec39f8d7f1113612c20801b973e3839de9c3f8b7b3", - "https://deno.land/x/cliffy@v0.20.1/table/deps.ts": "77babb7a6cea256b44dd5f19a1d7c23b9f4cea3fc841d9593d03602c4123bdda", - "https://deno.land/x/cliffy@v0.20.1/table/layout.ts": "b3055be2bc94dce0381094182121c026328ba9f173d6c1d0eb00a3dd7482b226", - "https://deno.land/x/cliffy@v0.20.1/table/row.ts": "707c8f9c71f28e7ded22a053c13c0988a9598eec0a9e2a7aea7d15cad64d3cfe", - "https://deno.land/x/cliffy@v0.20.1/table/table.ts": "3905c40d030626faab87a3e2e24982382c0d4c11eccae829a9e04db94d29e3e9", - "https://deno.land/x/cliffy@v0.20.1/table/utils.ts": "720bf8f10abf7bfbc8f3751708f3fafaf9a344706534e52b6cb30ed49882fd82", - "https://deno.land/x/cliffy@v0.25.7/_utils/distance.ts": "02af166952c7c358ac83beae397aa2fbca4ad630aecfcd38d92edb1ea429f004", - "https://deno.land/x/cliffy@v0.25.7/ansi/ansi.ts": "7f43d07d31dd7c24b721bb434c39cbb5132029fa4be3dd8938873065f65e5810", - "https://deno.land/x/cliffy@v0.25.7/ansi/ansi_escapes.ts": "885f61f343223f27b8ec69cc138a54bea30542924eacd0f290cd84edcf691387", - "https://deno.land/x/cliffy@v0.25.7/ansi/chain.ts": "31fb9fcbf72fed9f3eb9b9487270d2042ccd46a612d07dd5271b1a80ae2140a0", - "https://deno.land/x/cliffy@v0.25.7/ansi/colors.ts": "5f71993af5bd1aa0a795b15f41692d556d7c89584a601fed75997df844b832c9", - "https://deno.land/x/cliffy@v0.25.7/ansi/cursor_position.ts": "d537491e31d9c254b208277448eff92ff7f55978c4928dea363df92c0df0813f", - "https://deno.land/x/cliffy@v0.25.7/ansi/deps.ts": "0f35cb7e91868ce81561f6a77426ea8bc55dc15e13f84c7352f211023af79053", - "https://deno.land/x/cliffy@v0.25.7/ansi/mod.ts": "bb4e6588e6704949766205709463c8c33b30fec66c0b1846bc84a3db04a4e075", - "https://deno.land/x/cliffy@v0.25.7/ansi/tty.ts": "8fb064c17ead6cdf00c2d3bc87a9fd17b1167f2daa575c42b516f38bdb604673", - "https://deno.land/x/cliffy@v0.25.7/command/_errors.ts": "a9bd23dc816b32ec96c9b8f3057218241778d8c40333b43341138191450965e5", - "https://deno.land/x/cliffy@v0.25.7/command/_utils.ts": "9ab3d69fabab6c335b881b8a5229cbd5db0c68f630a1c307aff988b6396d9baf", - "https://deno.land/x/cliffy@v0.25.7/command/command.ts": "a2b83c612acd65c69116f70dec872f6da383699b83874b70fcf38cddf790443f", - "https://deno.land/x/cliffy@v0.25.7/command/completions/_bash_completions_generator.ts": "43b4abb543d4dc60233620d51e69d82d3b7c44e274e723681e0dce2a124f69f9", - "https://deno.land/x/cliffy@v0.25.7/command/completions/_fish_completions_generator.ts": "d0289985f5cf0bd288c05273bfa286b24c27feb40822eb7fd9d7fee64e6580e8", - "https://deno.land/x/cliffy@v0.25.7/command/completions/_zsh_completions_generator.ts": "14461eb274954fea4953ee75938821f721da7da607dc49bcc7db1e3f33a207bd", - "https://deno.land/x/cliffy@v0.25.7/command/completions/bash.ts": "053aa2006ec327ccecacb00ba28e5eb836300e5c1bec1b3cfaee9ddcf8189756", - "https://deno.land/x/cliffy@v0.25.7/command/completions/complete.ts": "58df61caa5e6220ff2768636a69337923ad9d4b8c1932aeb27165081c4d07d8b", - "https://deno.land/x/cliffy@v0.25.7/command/completions/fish.ts": "9938beaa6458c6cf9e2eeda46a09e8cd362d4f8c6c9efe87d3cd8ca7477402a5", - "https://deno.land/x/cliffy@v0.25.7/command/completions/mod.ts": "aeef7ec8e319bb157c39a4bab8030c9fe8fa327b4c1e94c9c1025077b45b40c0", - "https://deno.land/x/cliffy@v0.25.7/command/completions/zsh.ts": "8b04ab244a0b582f7927d405e17b38602428eeb347a9968a657e7ea9f40e721a", - "https://deno.land/x/cliffy@v0.25.7/command/deprecated.ts": "bbe6670f1d645b773d04b725b8b8e7814c862c9f1afba460c4d599ffe9d4983c", - "https://deno.land/x/cliffy@v0.25.7/command/deps.ts": "275b964ce173770bae65f6b8ebe9d2fd557dc10292cdd1ed3db1735f0d77fa1d", - "https://deno.land/x/cliffy@v0.25.7/command/help/_help_generator.ts": "f7c349cb2ddb737e70dc1f89bcb1943ca9017a53506be0d4138e0aadb9970a49", - "https://deno.land/x/cliffy@v0.25.7/command/help/mod.ts": "09d74d3eb42d21285407cda688074c29595d9c927b69aedf9d05ff3f215820d3", - "https://deno.land/x/cliffy@v0.25.7/command/mod.ts": "d0a32df6b14028e43bb2d41fa87d24bc00f9662a44e5a177b3db02f93e473209", - "https://deno.land/x/cliffy@v0.25.7/command/type.ts": "24e88e3085e1574662b856ccce70d589959648817135d4469fab67b9cce1b364", - "https://deno.land/x/cliffy@v0.25.7/command/types.ts": "ae02eec0ed7a769f7dba2dd5d3a931a61724b3021271b1b565cf189d9adfd4a0", - "https://deno.land/x/cliffy@v0.25.7/command/types/action_list.ts": "33c98d449617c7a563a535c9ceb3741bde9f6363353fd492f90a74570c611c27", - "https://deno.land/x/cliffy@v0.25.7/command/types/boolean.ts": "3879ec16092b4b5b1a0acb8675f8c9250c0b8a972e1e4c7adfba8335bd2263ed", - "https://deno.land/x/cliffy@v0.25.7/command/types/child_command.ts": "f1fca390c7fbfa7a713ca15ef55c2c7656bcbb394d50e8ef54085bdf6dc22559", - "https://deno.land/x/cliffy@v0.25.7/command/types/command.ts": "325d0382e383b725fd8d0ef34ebaeae082c5b76a1f6f2e843fee5dbb1a4fe3ac", - "https://deno.land/x/cliffy@v0.25.7/command/types/enum.ts": "2178345972adf7129a47e5f02856ca3e6852a91442a1c78307dffb8a6a3c6c9f", - "https://deno.land/x/cliffy@v0.25.7/command/types/file.ts": "8618f16ac9015c8589cbd946b3de1988cc4899b90ea251f3325c93c46745140e", - "https://deno.land/x/cliffy@v0.25.7/command/types/integer.ts": "29864725fd48738579d18123d7ee78fed37515e6dc62146c7544c98a82f1778d", - "https://deno.land/x/cliffy@v0.25.7/command/types/number.ts": "aeba96e6f470309317a16b308c82e0e4138a830ec79c9877e4622c682012bc1f", - "https://deno.land/x/cliffy@v0.25.7/command/types/string.ts": "e4dadb08a11795474871c7967beab954593813bb53d9f69ea5f9b734e43dc0e0", - "https://deno.land/x/cliffy@v0.25.7/command/upgrade/mod.ts": "17e2df3b620905583256684415e6c4a31e8de5c59066eb6d6c9c133919292dc4", - "https://deno.land/x/cliffy@v0.25.7/command/upgrade/provider.ts": "d6fb846043232cbd23c57d257100c7fc92274984d75a5fead0f3e4266dc76ab8", - "https://deno.land/x/cliffy@v0.25.7/command/upgrade/provider/deno_land.ts": "24f8d82e38c51e09be989f30f8ad21f9dd41ac1bb1973b443a13883e8ba06d6d", - "https://deno.land/x/cliffy@v0.25.7/command/upgrade/provider/github.ts": "99e1b133dd446c6aa79f69e69c46eb8bc1c968dd331c2a7d4064514a317c7b59", - "https://deno.land/x/cliffy@v0.25.7/command/upgrade/provider/nest_land.ts": "0e07936cea04fa41ac9297f32d87f39152ea873970c54cb5b4934b12fee1885e", - "https://deno.land/x/cliffy@v0.25.7/command/upgrade/upgrade_command.ts": "3640a287d914190241ea1e636774b1b4b0e1828fa75119971dd5304784061e05", - "https://deno.land/x/cliffy@v0.25.7/flags/_errors.ts": "f1fbb6bfa009e7950508c9d491cfb4a5551027d9f453389606adb3f2327d048f", - "https://deno.land/x/cliffy@v0.25.7/flags/_utils.ts": "340d3ecab43cde9489187e1f176504d2c58485df6652d1cdd907c0e9c3ce4cc2", - "https://deno.land/x/cliffy@v0.25.7/flags/_validate_flags.ts": "16eb5837986c6f6f7620817820161a78d66ce92d690e3697068726bbef067452", - "https://deno.land/x/cliffy@v0.25.7/flags/deprecated.ts": "a72a35de3cc7314e5ebea605ca23d08385b218ef171c32a3f135fb4318b08126", - "https://deno.land/x/cliffy@v0.25.7/flags/flags.ts": "68a9dfcacc4983a84c07ba19b66e5e9fccd04389fad215210c60fb414cc62576", - "https://deno.land/x/cliffy@v0.25.7/flags/mod.ts": "b21c2c135cd2437cc16245c5f168a626091631d6d4907ad10db61c96c93bdb25", - "https://deno.land/x/cliffy@v0.25.7/flags/types.ts": "7452ea5296758fb7af89930349ce40d8eb9a43b24b3f5759283e1cb5113075fd", - "https://deno.land/x/cliffy@v0.25.7/flags/types/boolean.ts": "4c026dd66ec9c5436860dc6d0241427bdb8d8e07337ad71b33c08193428a2236", - "https://deno.land/x/cliffy@v0.25.7/flags/types/integer.ts": "b60d4d590f309ddddf066782d43e4dc3799f0e7d08e5ede7dc62a5ee94b9a6d9", - "https://deno.land/x/cliffy@v0.25.7/flags/types/number.ts": "610936e2d29de7c8c304b65489a75ebae17b005c6122c24e791fbed12444d51e", - "https://deno.land/x/cliffy@v0.25.7/flags/types/string.ts": "e89b6a5ce322f65a894edecdc48b44956ec246a1d881f03e97bbda90dd8638c5", - "https://deno.land/x/cliffy@v0.25.7/keycode/key_code.ts": "c4ab0ffd102c2534962b765ded6d8d254631821bf568143d9352c1cdcf7a24be", - "https://deno.land/x/cliffy@v0.25.7/keycode/key_codes.ts": "917f0a2da0dbace08cf29bcfdaaa2257da9fe7e705fff8867d86ed69dfb08cfe", - "https://deno.land/x/cliffy@v0.25.7/keycode/mod.ts": "292d2f295316c6e0da6955042a7b31ab2968ff09f2300541d00f05ed6c2aa2d4", - "https://deno.land/x/cliffy@v0.25.7/mod.ts": "e3515ccf6bd4e4ac89322034e07e2332ed71901e4467ee5bc9d72851893e167b", - "https://deno.land/x/cliffy@v0.25.7/prompt/_generic_input.ts": "737cff2de02c8ce35250f5dd79c67b5fc176423191a2abd1f471a90dd725659e", - "https://deno.land/x/cliffy@v0.25.7/prompt/_generic_list.ts": "79b301bf09eb19f0d070d897f613f78d4e9f93100d7e9a26349ef0bfaa7408d2", - "https://deno.land/x/cliffy@v0.25.7/prompt/_generic_prompt.ts": "8630ce89a66d83e695922df41721cada52900b515385d86def597dea35971bb2", - "https://deno.land/x/cliffy@v0.25.7/prompt/_generic_suggestions.ts": "2a8b619f91e8f9a270811eff557f10f1343a444a527b5fc22c94de832939920c", - "https://deno.land/x/cliffy@v0.25.7/prompt/_utils.ts": "676cca30762656ed1a9bcb21a7254244278a23ffc591750e98a501644b6d2df3", - "https://deno.land/x/cliffy@v0.25.7/prompt/checkbox.ts": "e5a5a9adbb86835dffa2afbd23c6f7a8fe25a9d166485388ef25aba5dc3fbf9e", - "https://deno.land/x/cliffy@v0.25.7/prompt/confirm.ts": "94c8e55de3bbcd53732804420935c432eab29945497d1c47c357d236a89cb5f6", - "https://deno.land/x/cliffy@v0.25.7/prompt/deps.ts": "4c38ab18e55a792c9a136c1c29b2b6e21ea4820c45de7ef4cf517ce94012c57d", - "https://deno.land/x/cliffy@v0.25.7/prompt/figures.ts": "26af0fbfe21497220e4b887bb550fab997498cde14703b98e78faf370fbb4b94", - "https://deno.land/x/cliffy@v0.25.7/prompt/input.ts": "ee45532e0a30c2463e436e08ae291d79d1c2c40872e17364c96d2b97c279bf4d", - "https://deno.land/x/cliffy@v0.25.7/prompt/list.ts": "6780427ff2a932a48c9b882d173c64802081d6cdce9ff618d66ba6504b6abc50", - "https://deno.land/x/cliffy@v0.25.7/prompt/mod.ts": "195aed14d10d279914eaa28c696dec404d576ca424c097a5bc2b4a7a13b66c89", - "https://deno.land/x/cliffy@v0.25.7/prompt/number.ts": "015305a76b50138234dde4fd50eb886c6c7c0baa1b314caf811484644acdc2cf", - "https://deno.land/x/cliffy@v0.25.7/prompt/prompt.ts": "0e7f6a1d43475ee33fb25f7d50749b2f07fc0bcddd9579f3f9af12d05b4a4412", - "https://deno.land/x/cliffy@v0.25.7/prompt/secret.ts": "58745f5231fb2c44294c4acf2511f8c5bfddfa1e12f259580ff90dedea2703d6", - "https://deno.land/x/cliffy@v0.25.7/prompt/select.ts": "1e982eae85718e4e15a3ee10a5ae2233e532d7977d55888f3a309e8e3982b784", - "https://deno.land/x/cliffy@v0.25.7/prompt/toggle.ts": "842c3754a40732f2e80bcd4670098713e402e64bd930e6cab2b787f7ad4d931a", - "https://deno.land/x/cliffy@v0.25.7/table/border.ts": "2514abae4e4f51eda60a5f8c927ba24efd464a590027e900926b38f68e01253c", - "https://deno.land/x/cliffy@v0.25.7/table/cell.ts": "1d787d8006ac8302020d18ec39f8d7f1113612c20801b973e3839de9c3f8b7b3", - "https://deno.land/x/cliffy@v0.25.7/table/deps.ts": "5b05fa56c1a5e2af34f2103fd199e5f87f0507549963019563eae519271819d2", - "https://deno.land/x/cliffy@v0.25.7/table/layout.ts": "46bf10ae5430cf4fbb92f23d588230e9c6336edbdb154e5c9581290562b169f4", - "https://deno.land/x/cliffy@v0.25.7/table/mod.ts": "e74f69f38810ee6139a71132783765feb94436a6619c07474ada45b465189834", - "https://deno.land/x/cliffy@v0.25.7/table/row.ts": "5f519ba7488d2ef76cbbf50527f10f7957bfd668ce5b9169abbc44ec88302645", - "https://deno.land/x/cliffy@v0.25.7/table/table.ts": "ec204c9d08bb3ff1939c5ac7412a4c9ed7d00925d4fc92aff9bfe07bd269258d", - "https://deno.land/x/cliffy@v0.25.7/table/utils.ts": "187bb7dcbcfb16199a5d906113f584740901dfca1007400cba0df7dcd341bc29" - }, - "workspace": { - "dependencies": [ - "jsr:@std/assert@^1.0.13", - "jsr:@std/fs@1", - "jsr:@std/path@^1.1.0", - "jsr:@std/testing@1" - ] - } -} diff --git a/docs/deno-v2-migration-plan.md b/docs/deno-v2-migration-plan.md index 8e30f66..e0d55f1 100644 --- a/docs/deno-v2-migration-plan.md +++ b/docs/deno-v2-migration-plan.md @@ -1,4 +1,8 @@ -# Deno v2 マイグレーション計画 +# Deno v2 マイグレーション計画(アーカイブ) + +> **注記 (2026-07-05)**: 本プロジェクトは Go に移行し、Deno 版は廃止されました。 +> このドキュメントは歴史的経緯の記録として残しています。 +> 詳細は `docs/go-migration-plan.md` を参照してください。 ## 概要 diff --git a/docs/go-migration-plan.md b/docs/go-migration-plan.md new file mode 100644 index 0000000..335f3cc --- /dev/null +++ b/docs/go-migration-plan.md @@ -0,0 +1,305 @@ +# Go CLI マイグレーション計画 + +## 概要 + +このドキュメントは、nippo-cli(`sava` コマンド)を Deno / TypeScript 実装から Go 実装へ移行するためのマイグレーション計画を記載しています。 + +### 移行の目的(想定されるメリット) + +- **シングルバイナリ配布**: ランタイム(Deno)不要で `go install` や GitHub Releases 経由の配布が可能になる +- **起動速度**: CLI ツールとして起動オーバーヘッドが小さくなる +- **クロスコンパイル**: macOS / Linux / Windows 向けバイナリを容易に生成できる +- **依存の安定性**: Deno v2 / std / cliffy などのエコシステム変化に追従するコストがなくなる + +### 前提(決定事項) + +- 外部の利用者はいない(作者のみ)。**移行完了後、Deno 版は廃止する** +- 既存ログデータとの互換性維持は必須要件としない。フォーマットは結果的に互換となるが、旧データの救済処理(hash の再採番など)は行わない + +## 現状分析 + +### プロジェクト構造(2026-07 時点) + +- **総ファイル数**: TypeScript 22 ファイル(ソース 21、テスト 1)、約 990 行 +- **エントリポイント**: `src/sava.ts`(cliffy v0.20.1 によるコマンド定義) +- **レイヤ構成**: Command → Feature → LogFile(README 記載のアーキテクチャ) + +| レイヤ | ファイル | 役割 | +|--------|---------|------| +| entry | `src/sava.ts` | CLI 定義(cliffy) | +| commands | `add.ts` / `end.ts` / `delete.ts` / `list.ts` / `clear.ts` | 各サブコマンドの制御 | +| features | `logFile.ts` / `log.ts` / `generate.ts` / `hash.ts` / `path.ts` | ファイル I/O、ログ操作、表示整形 | +| models | `LogFile.ts` / `MemoItem.ts` / `TaskItem.ts` / `LogFileName.ts` / `Date.ts` / `factory/LogFileName.ts` | ドメインモデル | +| utils | `formatDate.ts` / `formatTime.ts` / `homeDir.ts` | 汎用ユーティリティ | +| const | `const.ts` | 定数(保存先、保持期間など) | + +### コマンド仕様(移行で維持すべき外部仕様) + +``` +sava add # タスク追加 +sava add -m # メモ追加 +sava end # タスク完了 +sava del # アイテム削除 +sava list [date] # 当日(または指定日)のログ表示 +sava list -s [date] # 統計表示 +sava list -a # 全ログファイル一覧 +sava list -a -s # 全ログファイル統計(10 件超で確認プロンプト) +sava clear # 保持期間(30 日)超過のログ削除 +sava clear -a # 全ログ削除(確認プロンプトあり) +sava help # ヘルプ +``` + +### データ仕様(最重要の互換性ポイント) + +- **保存先**: `$HOME/.log/sava/`(`HOME` / `USERPROFILE` 未設定時はカレントディレクトリ) +- **ファイル名**: `yyyy-MM-dd.json`(1 日 1 ファイル) +- **フォーマット**: インデント 2 スペースの JSON + +```json +{ + "hash": "...", + "freezed": false, + "items": [ + { + "hash": "...", + "createdAt": "ISO 8601 文字列", + "updatedAt": "ISO 8601 文字列", + "content": "本文", + "closed": false + } + ] +} +``` + +- `closed` キーの**有無**でタスク(あり)とメモ(なし)を判別している +- `freezed: true` のファイルは更新不可 + +### 移行前に判断が必要な既存実装の問題点 + +調査の過程で見つかった、単純移植すると問題を引き継ぐ箇所については、以下の通り。修正対象とする。 + +1. **ハッシュ生成のバグ(重大・退行)**: 初期実装では `std@0.77.0/hash` の + `createHash` を使用しており、その `Hash.toString()` はデフォルトで hex + ダイジェストを返すため**正常に動作していた**。Deno v2 移行 + (commit `53cbd7e` / `cb9fc4b`)で `node:crypto` に置き換えた際、 + `node:crypto` の Hash は `toString()` でダイジェストを返さない + (`digest('hex')` の呼び出しが必要)ため、それ以降に作成されたアイテムの + hash は壊れており、`end ` / `del ` で個別アイテムを特定できない。 + Go 版では新方式の ID 生成に置き換える(「ハッシュ(アイテム ID)の扱い」参照)。 +2. **`compareDatesInDescent` の名前と実装の不一致**: 名前は降順だが実装は昇順ソート。実態に合わせて修正する。 +3. **`requiredDateFormatHash` / `isDateString` の日付検証が緩い**: `new Date()` + のパース依存のため、`yyyy-M-d` などもゆるく通る。Go では + `time.Parse("2006-01-02", ...)` による厳密な検証に置き換える。修正する。 +4. **`getLogFile` 内の `updateLogFile` 呼び出しに `await` 漏れ**、 + `clear` の `forEach(async ...)` など、非同期処理の扱いに不備があるが、Go では同期処理になるため自然に解消される見込み。テストで期待通りの挙動になっていることを確認する。 +5. **`clear` が実際にはファイルを削除できない(重大・Phase 0 実測で発見)**: + `listLogFile` が `walk` の返す絶対パスを `logDir` と再結合するためパスが + 二重になり、`Deno.remove` が NotFound で失敗する。`clear` / `clear -a` とも + 「Deleted...」と表示するだけで削除は機能していない。Go 版ではパス解決を + 正しく実装する(意図した修正)。 + + +## Go 実装の方針 + +### 技術選定 + +| 項目 | 選定 | 理由 | +| ----------- | ------------------------ | --------------------------------------- | +| Go バージョン | 1.24 系(移行時の最新安定版) | `.mise.toml` で管理 | +| CLI フレームワーク | `spf13/cobra`(採用確定) | サブコマンド・フラグ・ヘルプ生成のデファクト | +| ハッシュ(アイテム ID) | `crypto/rand` によるランダム短縮 ID | 依存追加不要。詳細は「ハッシュ(アイテム ID)の扱い」参照 | +| JSON | 標準 `encoding/json` | `MarshalIndent` で 2 スペースインデント維持 | +| 日付 | 標準 `time` | `Intl.DateTimeFormat` 依存を排除 | +| テスト | 標準 `testing` + テーブル駆動 | 必要なら `google/go-cmp` を追加 | +| リリース | `goreleaser`(任意) | クロスコンパイルと GitHub Releases 配布 | + +外部依存は cobra(+ goreleaser)程度に抑え、標準ライブラリ中心で構成します。 + +### 実装ポリシー(決定事項) + +- **コマンド名**: バイナリ名・表示名とも `sava` に統一する(現行は実行名 `sava` と `APP_NAME = 'nippo-cli'` が不一致。リポジトリ名は `nippo-cli` のまま) +- **出力互換性**: 機能面でのデグレがないことを保証対象とし、表示テキストの完全一致は目指さない +- **確認プロンプト**: TTY での利用を前提とする。非対話環境(パイプ・CI)向けには、確認をスキップするオプション(`--yes` など)を用意する +- **エラー処理**: stderr に 1 行メッセージ + exit 1 を基本とする(スタックトレースは出力しない) +- **バージョニング**: Go 版初リリースを `v0.1.0` とする。利用者が現時点でいないため、厳密なリリース管理は行わない +- **コード品質 CI**: golangci-lint を導入する。既存の `sonar.yml` は TODO コメントをスキャンする CI であり無害なため、そのまま存続させる + +### ディレクトリ構成(案) + +``` +. +├── cmd/ +│ └── sava/ +│ └── main.go # エントリポイント(cobra ルートコマンド) +├── internal/ +│ ├── command/ # add / end / del / list / clear の実装 +│ ├── logfile/ # ログファイル I/O(features/logFile.ts 相当) +│ ├── log/ # アイテム操作(features/log.ts 相当) +│ ├── model/ # Item / Log / LogFile 構造体 +│ └── view/ # 表示整形(features/generate.ts 相当) +├── go.mod +├── .mise.toml # deno → go へ更新 +└── docs/ +``` + +### モデル定義(案) + +```go +type Item struct { + Hash string `json:"hash"` + CreatedAt string `json:"createdAt"` // ISO 8601 (RFC 3339) + UpdatedAt string `json:"updatedAt"` + Content string `json:"content"` + Closed *bool `json:"closed,omitempty"` // nil = メモ、非 nil = タスク +} + +type Log struct { + Hash string `json:"hash"` + Freezed bool `json:"freezed"` + Items []Item `json:"items"` +} +``` + +`closed` の有無によるタスク / メモ判別は `*bool` + `omitempty` で既存 JSON と完全互換にします。 + +### ハッシュ(アイテム ID)の扱い — 再検討結果 + +**経緯(なぜ壊れたか)**: 初期実装の `std@0.77.0/hash` では +`createHash('md5').update(x).toString()` が hex ダイジェストを返すため +正常に動作していた。Deno v2 移行で `node:crypto` に置き換えた際、 +`node:crypto` の Hash には同等の `toString()` がなく `digest('hex')` が +必要なため、以後の hash は壊れている(呼び出し側は無修正のまま)。 + +**現行設計の問題点(正しく hex 化しても残る問題)**: + +- ID の元が `md5(createdAt)` のため、同一タイムスタンプで作成された + アイテム同士は衝突する +- 用途は `end` / `del` でアイテムを特定する短い ID であり、 + 暗号学的ハッシュ(md5)である必要がない + +**Go 版の方式(決定)**: md5 の踏襲はやめ、代替方式へ移行する。 + +- `crypto/rand` で生成するランダム短縮 ID(hex 8 文字)を採用する +- JSON のキー名は `hash` のまま維持し、スキーマ変更はしない +- ファイル(Log)側の `hash` フィールドは現状どこからも参照されていないが、 + フォーマット維持のため同方式で生成を続ける(削除は移行完了後に別途検討) + +**旧データの扱い(決定)**: 利用者がいないため、再採番などの移行処理は行わない。 +旧ファイルの読み込み・表示はフォーマット互換のため可能だが、壊れた hash を持つ +旧アイテムへの `end` / `del` は保証しない。 + +## マイグレーション手順 + +### ブランチ運用(決定事項) + +- 移行用の `migrate` ブランチを作成し、Phase ごとのブランチを `migrate` に向けてマージしていく +- 移行完了後、デフォルトブランチを切り替えて Go 版を main に置く + +### Phase 0: 仕様の固定(サンプル採取) + +- [x] **0.1** 現行 Deno 版の入出力仕様を記録(2026-07-05 完了) + - 各コマンドの出力サンプルを `testdata/legacy-samples/outputs.md` に保存 + - サンプルのログ JSON を `testdata/legacy-samples/2026-07-05.json` に保存 + - 実測により hash バグ(`"[object Object]"` が保存される)と `clear` のパス二重化バグ(前述 5)を確認 +- [x] **0.2** 互換性の範囲を決定(2026-07-05 決定済み) + - **維持する**: コマンド体系、ログ JSON フォーマット、保存先パス、出力される情報(文言の完全一致は目指さない) + - **修正する**: ハッシュ生成バグ、日付検証、ソート順の名称不一致(前述 4 点すべて) + - **既存ログファイルの hash は移行対象外**: 利用者がいないため再採番は行わず、新規アイテムから新方式の ID を採用する(「ハッシュ(アイテム ID)の扱い」参照) + +### Phase 1: Go プロジェクトの土台作り + +- [x] **1.1** `go mod init`、ディレクトリ構成の作成 +- [x] **1.2** `.mise.toml` に go を追加(deno と一時併存)※ Go 1.26.4(計画時の 1.24 想定から更新) +- [x] **1.3** cobra でコマンドの骨組み(`add` / `end` / `del` / `list` / `clear`)を定義 +- [x] **1.4** CI に Go 用ジョブを追加(`gofmt` / `go vet` / `go test` / `golangci-lint`、`go_ci.yml`) + - 既存の `wc_ci.yml`(Deno 用)と並走させる + +### Phase 2: ドメイン層の移植 + +- [x] **2.1** `internal/model`: `Item` / `Log` / `LogFile` 構造体と JSON 互換の確認 + - レガシーファイルとのバイト一致ラウンドトリップテストで確認済み +- [x] **2.2** `internal/logfile`: 読み書き・一覧・新規作成(`~/.log/sava` 解決を含む、パス二重化バグ修正) +- [x] **2.3** `internal/log`: addItem / deleteItem / finishTaskItem / listItems の移植 + - ID 生成を新方式(`crypto/rand` による 8 文字 hex)に置き換え済み +- [x] **2.4** `internal/view`: 出力整形の移植(Phase 0 のサンプルと比較し、表示される情報に過不足がないことを確認) + +### Phase 3: コマンド層の移植 + +- [x] **3.1** `add`(`-m` フラグ含む) +- [x] **3.2** `end` / `del` +- [x] **3.3** `list`(`-a` / `-s` / 日付指定、確認プロンプト含む) +- [x] **3.4** `clear`(`-a`、確認プロンプト含む) +- [x] **3.5** 確認プロンプトのスキップオプション(`--yes` / `-y`)の追加(非対話環境向け) +- [x] **3.6** `help` / `--version`(`v0.1.0`、表示名 `sava`) + +### Phase 4: 検証と切り替え + +- [x] **4.1** 実データでの並行動作確認(Deno 版で作った `~/.log/sava` を Go 版で読み書き、逆方向も確認) +- [x] **4.2** Phase 0 のサンプルと比較し、機能面のデグレがないことを確認 + - 加えて hash バグ・`clear` バグの修正が意図通り機能することを実機確認 +- [x] **4.3** README の Usage を Go 版に更新 +- [x] **4.4** `go install` 手順を README に整備(goreleaser は任意のため今回は見送り、必要になったら導入) + +### Phase 5: Deno 資産の撤去 + +- [x] **5.1** `src/`、`deno.json`、`deno.lock` の削除(`.vscode/` の Deno 設定も削除) +- [x] **5.2** CI から Deno ジョブ(`ci.yml` / `wc_ci.yml`)を削除。Go 用 paths-filter は `go_ci.yml` に設定済み(`sonar.yml` は存続) +- [x] **5.3** `.mise.toml` から deno を削除 +- [x] **5.4** `docs/deno-v2-migration-plan.md` にアーカイブ注記を追加 + +## 移行完了 + +全フェーズが 2026-07-05 に完了。残作業は GitHub 上でのデフォルトブランチ切り替え +(`migrate` → main への反映)のみ。 + +## 想定される課題と対策 + +### 1. 既存ログファイルとの互換性(リスク: 低・方針決定済み) + +**問題**: 既存ファイルの hash はバグにより壊れており、Go 版の新方式 ID とは互換がない。 +**対策(決定)**: 利用者がいないため移行処理は行わない。フォーマット自体は互換のため旧ファイルの読み込み・表示(`list`)は可能とし、壊れた hash を持つ旧アイテムへの `end` / `del` は非サポートとする。 + +### 2. 出力・プロンプトの再現(リスク: 低) + +**問題**: `confirm()`(y/N プロンプト)や `Intl.DateTimeFormat` の時刻表記(`HH:mm`、en-US の 24 時間表記)の再現。 +**対策**: プロンプトは `bufio.Scanner` で自前実装(数行)し、TTY 前提とする。非対話環境向けにはスキップオプション(`--yes` など)を用意する。時刻は `time.Format("15:04")`、日付は `time.Format("2006-01-02")` で同等になる。タイムゾーンはローカルタイムを使用(現行と同じ)。文言の完全一致は目指さない(「実装ポリシー」参照)。 + +### 3. 日付まわりの挙動差(リスク: 低) + +**問題**: 現行は `new Date(string)` の緩いパースに依存(`list 2026-7-5` なども通る)。Go の `time.Parse` は厳密。 +**対策**: `yyyy-MM-dd` のみ受け付ける仕様として明文化(厳密化は意図した変更とする)。 + +### 4. Deno 版と Go 版の併存期間(リスク: 低) + +**問題**: 移行中に main ブランチが「どちらが正か」曖昧になる。 +**対策**: Go 版は `migrate` ブランチで開発し、移行完了まで main(Deno 版)を正とする。移行完了後にデフォルトブランチを切り替えて Go 版を main に置き、**Deno 版はその時点で廃止する**(利用者がいないため、切り替え時期の調整や告知は不要)。データディレクトリが共有されるため、並行利用しても実害はない。 + +## スケジュール + +| フェーズ | 想定工数 | 説明 | +|---------|---------|------| +| Phase 0 | 1-2時間 | 仕様固定・ゴールデンテスト・互換方針の決定 | +| Phase 1 | 1-2時間 | Go プロジェクト土台・CI 併設 | +| Phase 2 | 3-4時間 | ドメイン層の移植とテスト | +| Phase 3 | 2-3時間 | コマンド層の移植 | +| Phase 4 | 1-2時間 | 検証・ドキュメント・配布整備 | +| Phase 5 | 1時間 | Deno 資産の撤去 | +| **合計** | **9-14時間** | | + +## 参考資料 + +- [cobra](https://github.com/spf13/cobra) +- [goreleaser](https://goreleaser.com/) +- [Go time パッケージ(レイアウト仕様)](https://pkg.go.dev/time) +- 現行仕様: `README.md`、`docs/deno-v2-migration-plan.md` + +## 次のステップ + +1. `migrate` ブランチを作成し、Phase 0 から順次実行する(Phase ごとにブランチを切り `migrate` へマージ) +2. 問題が発生した場合は本計画書を更新する + +※ 互換性の範囲・hash の扱い・実行前の判断ポイント(コマンド名、出力互換、CLI フレームワーク、プロンプト、エラー処理、バージョニング、CI、ブランチ運用)は 2026-07-05 にすべて決定済み。内容は「前提」「実装ポリシー」「ブランチ運用」の各セクションに反映済み + +--- +*最終更新: 2026-07-05* +*作成者: Claude Code* diff --git a/docs/update-command-and-features.md b/docs/update-command-and-features.md new file mode 100644 index 0000000..313aaef --- /dev/null +++ b/docs/update-command-and-features.md @@ -0,0 +1,40 @@ + +このCLIはなにか? + +もともとの目的はユーザーの工数管理のためのものである +あとからカレンダーをスキャンしてその分のログも差し込んで統計出せると嬉しい気がする +そうするともう少し MemoItem ひとつひとつのライフサイクルを見直したほうがいいかも? + +今となっては AI Agent の外部記憶としての補助ツール、ユーザーとのコラボレーション補助ツールとしての小さい部品となれば嬉しい気がする + +## 既存のコマンド +``` +sava add # タスク追加 +sava add -m # メモ追加 +sava end # タスク完了 +sava del # アイテム削除 +sava list [date] # 当日(または指定日)のログ表示 +sava list -s [date] # 統計表示 +sava list -a # 全ログファイル一覧 +sava list -a -s # 全ログファイル統計(10 件超で確認プロンプト) +sava clear # 保持期間(30 日)超過のログ削除 +sava clear -a # 全ログ削除(確認プロンプトあり) +sava help # ヘルプ +``` + + +`sava end` のときに自動でメモを追加する +* タスクの開始と終了時の記録のためにメモしたい、と思ったがupdateAtをとっているので不要そう +* むしろ、タスク開始のコマンドも足していいかもしれない `sava start ` +* タスクの記録時に同時に作業着手するオプション足すといいかも +* そうすると中断も欲しいか? 厳密な作業時間の計測をしたい人向けなのでこれは優先度が低い + +`sava diff ...` の実装をしたい +* もとは hash 同士指定したらその間の時間をアウトプットしてもらえる、工数管理向けの機能で考えていた +* 詳細仕様を検討してもいいかもしれない + +MemoItem への tag づけ機能 +* 複数の tag を自由につけることができるようにし、アイテムのフィルタリングなどもできるようにする +* 存在する tag は後で管理するのが大変になるので、tagづけするコマンドが走ったらインデックス相当のファイルを吐き出しておくといいかも +* 複数タグ付けができる +* list時には曖昧検索、AND, OR検索をサポートしたい \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c168b17 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/rn404/nippo-cli + +go 1.26.4 + +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/command/command.go b/internal/command/command.go new file mode 100644 index 0000000..103fb42 --- /dev/null +++ b/internal/command/command.go @@ -0,0 +1,224 @@ +// Package command implements the CLI subcommands. Each function takes +// the log directory and writer/reader explicitly so behavior is +// testable without touching the real home directory. +package command + +import ( + "bufio" + "fmt" + "io" + "strings" + "time" + + "github.com/rn404/nippo-cli/internal/log" + "github.com/rn404/nippo-cli/internal/logfile" + "github.com/rn404/nippo-cli/internal/model" + "github.com/rn404/nippo-cli/internal/view" +) + +const ( + // storagePeriodDays is how long daily logs are kept by clear. + storagePeriodDays = 30 + // fileStatsLimit is the file count above which list -a -s asks + // for confirmation before loading everything. + 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 { + file, err := logfile.Get(dir, "") + if err != nil { + return err + } + if err := log.Add(&file.Body, content, !memo); err != nil { + return err + } + return logfile.Update(dir, file.Name, file.Body) +} + +// End closes the task matching hash in today's log. +func End(w io.Writer, dir, hash string) error { + file, err := logfile.Get(dir, "") + if err != nil { + return err + } + + finished, err := log.Finish(&file.Body, hash) + if err != nil { + return err + } + + view.FinishedTask(w, finished) + return logfile.Update(dir, file.Name, file.Body) +} + +// Del removes the item matching hash from today's log. +func Del(dir, hash string) error { + file, err := logfile.Get(dir, "") + if err != nil { + return err + } + + log.Delete(&file.Body, hash) + return logfile.Update(dir, file.Name, file.Body) +} + +// ListOptions controls the list command behavior. +type ListOptions struct { + Date string // yyyy-MM-dd; empty means today + All bool + Stat bool + Yes bool // skip confirmation prompts +} + +// List shows the items of one day, or summaries across all log files. +func List(w io.Writer, r io.Reader, dir string, opts ListOptions) error { + if !opts.All { + return listOneDay(w, dir, opts) + } + + refs, err := logfile.List(dir) + if err != nil { + return err + } + + if !opts.Stat { + view.Header(w, "The logs here are...") + for _, ref := range refs { + view.ListItem(w, ref.Name) + } + return nil + } + + view.Header(w, fmt.Sprintf("View all log statistics. There are %d total.", len(refs))) + if len(refs) > fileStatsLimit && !opts.Yes { + message := fmt.Sprintf( + "There are more than %d log files. It may take some time to display all of them. Are you sure you want to view them?", + fileStatsLimit, + ) + if !confirm(w, r, message) { + return nil + } + } + + for _, ref := range refs { + file, err := logfile.Get(dir, ref.Name) + if err != nil { + return err + } + writeFileStat(w, file) + } + return nil +} + +func listOneDay(w io.Writer, dir string, opts ListOptions) error { + file, err := logfile.Stat(dir, opts.Date) + if err != nil { + return err + } + + if opts.Stat { + if opts.Date == "" { + view.Header(w, "Today's log stats are...") + } else { + view.Header(w, fmt.Sprintf("Log stats for %s are...", opts.Date)) + } + if file == nil { + fmt.Fprintln(w, "There is no body...") + return nil + } + writeFileStat(w, file) + return nil + } + + if opts.Date == "" { + view.Header(w, "Today's logs are...") + } else { + view.Header(w, fmt.Sprintf("Log for %s are...", opts.Date)) + } + if file == nil { + fmt.Fprintln(w, "There is no body...") + return nil + } + + tasks, memos := log.Split(file.Body) + view.ItemList(w, tasks, memos) + return nil +} + +func writeFileStat(w io.Writer, file *logfile.LogFile) { + tasks, memos := log.Split(file.Body) + view.FileStat(w, file.Name, file.Body.Freezed, tasks, memos, log.CountUnfinished(tasks)) +} + +// Clear deletes old log files, or all of them when all is true. +func Clear(w io.Writer, r io.Reader, dir string, all, yes bool) error { + if all { + return clearAll(w, r, dir, yes) + } + return clearOld(w, dir) +} + +func clearAll(w io.Writer, r io.Reader, dir string, yes bool) error { + if !yes && !confirm(w, r, "Do you want to delete all the files?") { + return nil + } + + refs, err := logfile.List(dir) + if err != nil { + return err + } + if len(refs) == 0 { + fmt.Fprintln(w, "There is no log files.") + return nil + } + + for _, ref := range refs { + if err := logfile.Remove(ref); err != nil { + return err + } + fmt.Fprintf(w, "Deleted... %s logs.\n", ref.Name) + } + + fmt.Fprintln(w, "Deleted all files.") + return nil +} + +func clearOld(w io.Writer, dir string) error { + view.Header(w, fmt.Sprintf("Delete logs that are past their storage period. ( Storage period: %d days )", storagePeriodDays)) + + refs, err := logfile.List(dir) + if err != nil { + return err + } + + deadline := time.Now().AddDate(0, 0, -storagePeriodDays) + for _, ref := range refs { + date, err := model.ParseDate(ref.Name) + if err != nil { + continue + } + if date.Before(deadline) { + if err := logfile.Remove(ref); err != nil { + return err + } + fmt.Fprintf(w, "Deleted... %s logs.\n", ref.Name) + } + } + return nil +} + +// confirm asks a yes/no question and returns true only on an explicit +// yes. Any read failure (e.g. closed stdin) counts as no. +func confirm(w io.Writer, r io.Reader, message string) bool { + fmt.Fprintf(w, "%s [y/N] ", message) + + line, err := bufio.NewReader(r).ReadString('\n') + if err != nil && line == "" { + fmt.Fprintln(w) + return false + } + + answer := strings.ToLower(strings.TrimSpace(line)) + return answer == "y" || answer == "yes" +} diff --git a/internal/command/command_test.go b/internal/command/command_test.go new file mode 100644 index 0000000..7bcd6c5 --- /dev/null +++ b/internal/command/command_test.go @@ -0,0 +1,244 @@ +package command + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/rn404/nippo-cli/internal/logfile" + "github.com/rn404/nippo-cli/internal/model" +) + +func todayItems(t *testing.T, dir string) []model.Item { + t.Helper() + file, err := logfile.Stat(dir, "") + if err != nil { + t.Fatal(err) + } + if file == nil { + return nil + } + return file.Body.Items +} + +func TestAddEndDelFlow(t *testing.T) { + dir := t.TempDir() + + if err := Add(dir, "buy cabbage", false); err != nil { + t.Fatal(err) + } + if err := Add(dir, "a memo", true); err != nil { + t.Fatal(err) + } + + items := todayItems(t, dir) + if len(items) != 2 { + t.Fatalf("items = %d, want 2", len(items)) + } + task, memo := items[0], items[1] + if !task.IsTask() || memo.IsTask() { + t.Fatalf("expected one task and one memo: %+v", items) + } + + var out strings.Builder + if err := End(&out, dir, task.Hash); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Finished!!") { + t.Errorf("End output = %q", out.String()) + } + if items := todayItems(t, dir); !items[0].IsClosed() { + t.Errorf("task should be closed after End: %+v", items[0]) + } + + if err := Del(dir, memo.Hash); err != nil { + t.Fatal(err) + } + if items := todayItems(t, dir); len(items) != 1 { + t.Errorf("items after Del = %+v, want only the task", items) + } +} + +func TestEndErrors(t *testing.T) { + dir := t.TempDir() + if err := Add(dir, "a memo", true); err != nil { + t.Fatal(err) + } + memo := todayItems(t, dir)[0] + + var out strings.Builder + if err := End(&out, dir, "no-such-hash"); err == nil { + t.Errorf("End with unknown hash should fail") + } + if err := End(&out, dir, memo.Hash); err == nil { + t.Errorf("End on memo should fail") + } +} + +func TestListToday(t *testing.T) { + dir := t.TempDir() + if err := Add(dir, "buy cabbage", false); err != nil { + t.Fatal(err) + } + if err := Add(dir, "shrimp memo", true); err != nil { + t.Fatal(err) + } + + var out strings.Builder + if err := List(&out, strings.NewReader(""), dir, ListOptions{}); err != nil { + t.Fatal(err) + } + + for _, want := range []string{"Today's logs are...", "Task ->", "buy cabbage", "Memo ->", "shrimp memo"} { + if !strings.Contains(out.String(), want) { + t.Errorf("list output should contain %q:\n%s", want, out.String()) + } + } +} + +func TestListEmptyAndInvalidDate(t *testing.T) { + dir := t.TempDir() + + var out strings.Builder + if err := List(&out, strings.NewReader(""), dir, ListOptions{}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "There is no body...") { + t.Errorf("empty list output = %q", out.String()) + } + + if err := List(&out, strings.NewReader(""), dir, ListOptions{Date: "not-a-date"}); err == nil { + t.Errorf("invalid date should return an error") + } +} + +func TestListStatAndAll(t *testing.T) { + dir := t.TempDir() + if err := Add(dir, "buy cabbage", false); err != nil { + t.Fatal(err) + } + + var out strings.Builder + if err := List(&out, strings.NewReader(""), dir, ListOptions{Stat: true}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Task: 1 (unfinished: 1), Memo: 0") { + t.Errorf("stat output = %q", out.String()) + } + + out.Reset() + if err := List(&out, strings.NewReader(""), dir, ListOptions{All: true}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), model.Today()) { + t.Errorf("list -a output should contain today's file name: %q", out.String()) + } + + out.Reset() + if err := List(&out, strings.NewReader(""), dir, ListOptions{All: true, Stat: true}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "There are 1 total.") { + t.Errorf("list -a -s output = %q", out.String()) + } +} + +func TestListAllStatConfirmDeclined(t *testing.T) { + dir := t.TempDir() + // Create more files than fileStatsLimit to trigger the prompt. + for month := 1; month <= fileStatsLimit+1; month++ { + day := time2date(2026, month, 1) + if _, err := logfile.Get(dir, day); err != nil { + t.Fatal(err) + } + } + + var out strings.Builder + if err := List(&out, strings.NewReader("n\n"), dir, ListOptions{All: true, Stat: true}); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "Task:") { + t.Errorf("stats should not be shown after declining: %q", out.String()) + } + + // With Yes the prompt is skipped. + out.Reset() + if err := List(&out, strings.NewReader(""), dir, ListOptions{All: true, Stat: true, Yes: true}); err != nil { + t.Fatal(err) + } + if got := strings.Count(out.String(), "Task:"); got != fileStatsLimit+1 { + t.Errorf("stat lines = %d, want %d", got, fileStatsLimit+1) + } +} + +func TestClearOld(t *testing.T) { + dir := t.TempDir() + if _, err := logfile.Get(dir, "2000-01-01"); err != nil { + t.Fatal(err) + } + if err := Add(dir, "recent", false); err != nil { + t.Fatal(err) + } + + var out strings.Builder + if err := Clear(&out, strings.NewReader(""), dir, false, false); err != nil { + t.Fatal(err) + } + + if !strings.Contains(out.String(), "Deleted... 2000-01-01 logs.") { + t.Errorf("clear output = %q", out.String()) + } + refs, err := logfile.List(dir) + if err != nil { + t.Fatal(err) + } + if len(refs) != 1 || refs[0].Name != model.Today() { + 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)") + } +} + +func TestClearAll(t *testing.T) { + dir := t.TempDir() + if err := Add(dir, "content", false); err != nil { + t.Fatal(err) + } + + // Declined: nothing happens. + var out strings.Builder + if err := Clear(&out, strings.NewReader("n\n"), dir, true, false); err != nil { + t.Fatal(err) + } + if refs, _ := logfile.List(dir); len(refs) != 1 { + t.Errorf("declining should keep files, got %+v", refs) + } + + // Accepted: files removed. + out.Reset() + if err := Clear(&out, strings.NewReader("y\n"), dir, true, false); err != nil { + t.Fatal(err) + } + if refs, _ := logfile.List(dir); len(refs) != 0 { + t.Errorf("all files should be deleted, got %+v", refs) + } + if !strings.Contains(out.String(), "Deleted all files.") { + t.Errorf("clear -a output = %q", out.String()) + } + + // --yes skips the prompt entirely. + out.Reset() + if err := Clear(&out, strings.NewReader(""), dir, true, true); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "There is no log files.") { + t.Errorf("clear -a on empty dir output = %q", out.String()) + } +} + +func time2date(year, month, day int) string { + return fmt.Sprintf("%04d-%02d-%02d", year, month, day) +} diff --git a/internal/log/log.go b/internal/log/log.go new file mode 100644 index 0000000..f3979d1 --- /dev/null +++ b/internal/log/log.go @@ -0,0 +1,100 @@ +// Package log provides operations on the items of a daily log. +package log + +import ( + "errors" + "fmt" + "sort" + + "github.com/rn404/nippo-cli/internal/model" +) + +var ( + // ErrFreezed is returned when modifying items of a frozen log. + ErrFreezed = errors.New("this log file is freezed, no updates") + // ErrNotTask is returned when finishing an item that is a memo. + 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") +) + +// Add appends a new task or memo to the log. +func Add(l *model.Log, content string, isTask bool) error { + if l.Freezed { + return ErrFreezed + } + + if isTask { + l.Items = append(l.Items, model.NewTaskItem(content)) + } else { + l.Items = append(l.Items, model.NewMemoItem(content)) + } + return nil +} + +// Delete removes all items matching hash from the log. +func Delete(l *model.Log, hash string) { + items := l.Items[:0] + for _, item := range l.Items { + if item.Hash != hash { + items = append(items, item) + } + } + l.Items = items +} + +// Finish closes the task matching hash and returns the updated item. +func Finish(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 + } + + closed := true + item.Closed = &closed + item.UpdatedAt = model.NowISO() + l.Items[i] = item + return item, nil + } + + return model.Item{}, fmt.Errorf("target item %q is not found", hash) +} + +// 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) { + for _, item := range l.Items { + if item.IsTask() { + tasks = append(tasks, item) + } else { + memos = append(memos, 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 +} + +// CountUnfinished returns the number of open tasks. +func CountUnfinished(tasks []model.Item) int { + count := 0 + for _, task := range tasks { + if !task.IsClosed() { + count++ + } + } + return count +} diff --git a/internal/log/log_test.go b/internal/log/log_test.go new file mode 100644 index 0000000..1955d77 --- /dev/null +++ b/internal/log/log_test.go @@ -0,0 +1,115 @@ +package log + +import ( + "errors" + "testing" + + "github.com/rn404/nippo-cli/internal/model" +) + +func newTestLog() model.Log { + closed := true + open := false + return model.Log{ + Hash: "filehash", + Freezed: false, + Items: []model.Item{ + {Hash: "task-open", Content: "open task", CreatedAt: "2026-07-05T02:00:00.000Z", UpdatedAt: "2026-07-05T02:00:00.000Z", Closed: &open}, + {Hash: "task-done", Content: "done task", CreatedAt: "2026-07-05T01:00:00.000Z", UpdatedAt: "2026-07-05T01:30:00.000Z", Closed: &closed}, + {Hash: "memo-1", Content: "a memo", CreatedAt: "2026-07-05T03:00:00.000Z", UpdatedAt: "2026-07-05T03:00:00.000Z"}, + }, + } +} + +func TestAdd(t *testing.T) { + l := newTestLog() + if err := Add(&l, "new task", true); err != nil { + t.Fatal(err) + } + if err := Add(&l, "new memo", false); err != nil { + t.Fatal(err) + } + + if len(l.Items) != 5 { + t.Fatalf("items = %d, want 5", len(l.Items)) + } + if !l.Items[3].IsTask() { + t.Errorf("appended item should be a task: %+v", l.Items[3]) + } + if l.Items[4].IsTask() { + t.Errorf("appended item should be a memo: %+v", l.Items[4]) + } +} + +func TestAddToFreezedLog(t *testing.T) { + l := newTestLog() + l.Freezed = true + if err := Add(&l, "content", true); !errors.Is(err, ErrFreezed) { + t.Errorf("err = %v, want ErrFreezed", err) + } +} + +func TestDelete(t *testing.T) { + l := newTestLog() + Delete(&l, "memo-1") + if len(l.Items) != 2 { + t.Fatalf("items = %d, want 2", len(l.Items)) + } + for _, item := range l.Items { + if item.Hash == "memo-1" { + t.Errorf("memo-1 should be deleted") + } + } + + Delete(&l, "no-such-hash") + if len(l.Items) != 2 { + t.Errorf("delete with unknown hash should be a no-op") + } +} + +func TestFinish(t *testing.T) { + l := newTestLog() + finished, err := Finish(&l, "task-open") + if err != nil { + t.Fatal(err) + } + if !finished.IsClosed() { + t.Errorf("finished item should be closed: %+v", finished) + } + if finished.UpdatedAt == finished.CreatedAt { + t.Errorf("updatedAt should be renewed on finish") + } + if !l.Items[0].IsClosed() { + t.Errorf("log should hold the closed item: %+v", l.Items[0]) + } +} + +func TestFinishErrors(t *testing.T) { + l := newTestLog() + + if _, err := Finish(&l, "no-such-hash"); err == nil { + t.Errorf("finishing unknown hash should fail") + } + if _, err := Finish(&l, "memo-1"); !errors.Is(err, ErrNotTask) { + t.Errorf("err = %v, want ErrNotTask", err) + } + if _, err := Finish(&l, "task-done"); !errors.Is(err, ErrAlreadyFinished) { + t.Errorf("err = %v, want ErrAlreadyFinished", err) + } +} + +func TestSplit(t *testing.T) { + tasks, memos := Split(newTestLog()) + + if len(tasks) != 2 || len(memos) != 1 { + t.Fatalf("tasks = %d, memos = %d, want 2 and 1", len(tasks), len(memos)) + } + // Sorted by createdAt ascending: task-done (01:00) before task-open (02:00). + if tasks[0].Hash != "task-done" || tasks[1].Hash != "task-open" { + t.Errorf("tasks should be sorted by createdAt: %+v", tasks) + } + + if got := CountUnfinished(tasks); got != 1 { + t.Errorf("CountUnfinished = %d, want 1", got) + } +} diff --git a/internal/logfile/logfile.go b/internal/logfile/logfile.go new file mode 100644 index 0000000..a6c5786 --- /dev/null +++ b/internal/logfile/logfile.go @@ -0,0 +1,167 @@ +// Package logfile handles reading and writing daily log files stored +// under ~/.log/sava/.json. +package logfile + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/rn404/nippo-cli/internal/model" +) + +const ( + logDirName = ".log/sava" + fileExt = ".json" +) + +// ErrFreezed is returned when attempting to update a frozen log file. +var ErrFreezed = errors.New("this log file is freezed, no updates") + +// LogFile is a loaded daily log file. +type LogFile struct { + Path string // full path including file name + Name string // yyyy-MM-dd (file name without extension) + Body model.Log +} + +// Ref points to a log file on disk without loading its body. +type Ref struct { + Path string + Name string // yyyy-MM-dd +} + +// Dir returns the log directory. It prefers the home directory and +// falls back to the current working directory, like the Deno version. +func Dir() string { + root, err := os.UserHomeDir() + if err != nil || root == "" { + root, _ = os.Getwd() + } + return filepath.Join(root, logDirName) +} + +func pathFor(dir, name string) string { + return filepath.Join(dir, name+fileExt) +} + +// resolveName returns the file name (yyyy-MM-dd) for day, defaulting +// to today when day is empty. +func resolveName(day string) (string, error) { + if day == "" { + return model.Today(), nil + } + if _, err := model.ParseDate(day); err != nil { + return "", err + } + return day, nil +} + +// Stat loads the log file for day (today if empty). It returns nil +// without error when the file does not exist. +func Stat(dir, day string) (*LogFile, error) { + name, err := resolveName(day) + if err != nil { + return nil, err + } + + path := pathFor(dir, name) + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + + var body model.Log + if err := json.Unmarshal(data, &body); err != nil { + return nil, fmt.Errorf("broken log file %s: %w", path, err) + } + + return &LogFile{Path: path, Name: name, Body: body}, nil +} + +// Get loads the log file for day (today if empty), creating an empty +// one when it does not exist yet. +func Get(dir, day string) (*LogFile, error) { + file, err := Stat(dir, day) + if err != nil { + return nil, err + } + if file != nil { + return file, nil + } + + name, err := resolveName(day) + if err != nil { + return nil, err + } + + body := model.NewLog() + if err := Update(dir, name, body); err != nil { + return nil, err + } + + return &LogFile{Path: pathFor(dir, name), Name: name, Body: body}, nil +} + +// Update writes body to the log file for day. Frozen logs are rejected. +func Update(dir, day string, body model.Log) error { + if body.Freezed { + return ErrFreezed + } + + name, err := resolveName(day) + if err != nil { + return err + } + + // Logs are personal notes: keep them readable by the owner only. + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + + data, err := json.MarshalIndent(body, "", " ") + if err != nil { + return err + } + + return os.WriteFile(pathFor(dir, name), data, 0o600) +} + +// List returns refs to all daily log files in dir, sorted by date in +// ascending order. Files whose name is not a strict date are skipped. +func List(dir string) ([]Ref, error) { + entries, err := os.ReadDir(dir) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + + var refs []Ref + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), fileExt) { + continue + } + name := strings.TrimSuffix(entry.Name(), fileExt) + if !model.IsDateString(name) { + continue + } + refs = append(refs, Ref{Path: filepath.Join(dir, entry.Name()), Name: name}) + } + + sort.Slice(refs, func(i, j int) bool { return refs[i].Name < refs[j].Name }) + return refs, nil +} + +// Remove deletes the log file that ref points to. +func Remove(ref Ref) error { + return os.Remove(ref.Path) +} diff --git a/internal/logfile/logfile_test.go b/internal/logfile/logfile_test.go new file mode 100644 index 0000000..a2440bc --- /dev/null +++ b/internal/logfile/logfile_test.go @@ -0,0 +1,176 @@ +package logfile + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/rn404/nippo-cli/internal/log" + "github.com/rn404/nippo-cli/internal/model" +) + +func TestStatMissingFile(t *testing.T) { + file, err := Stat(t.TempDir(), "2026-07-05") + if err != nil { + t.Fatal(err) + } + if file != nil { + t.Errorf("Stat for missing file should return nil, got %+v", file) + } +} + +func TestGetCreatesNewFile(t *testing.T) { + dir := t.TempDir() + file, err := Get(dir, "2026-07-05") + if err != nil { + t.Fatal(err) + } + + if file.Name != "2026-07-05" { + t.Errorf("Name = %q, want 2026-07-05", file.Name) + } + if len(file.Body.Items) != 0 { + t.Errorf("new log should be empty: %+v", file.Body) + } + if _, err := os.Stat(filepath.Join(dir, "2026-07-05.json")); err != nil { + t.Errorf("file should be created on disk: %v", err) + } +} + +func TestUpdateAndReload(t *testing.T) { + dir := t.TempDir() + file, err := Get(dir, "2026-07-05") + if err != nil { + t.Fatal(err) + } + + if err := log.Add(&file.Body, "buy cabbage", true); err != nil { + t.Fatal(err) + } + if err := Update(dir, file.Name, file.Body); err != nil { + t.Fatal(err) + } + + reloaded, err := Stat(dir, "2026-07-05") + if err != nil { + t.Fatal(err) + } + if reloaded == nil || len(reloaded.Body.Items) != 1 { + t.Fatalf("reloaded = %+v, want 1 item", reloaded) + } + if reloaded.Body.Items[0].Content != "buy cabbage" { + t.Errorf("content = %q", reloaded.Body.Items[0].Content) + } +} + +func TestFilePermissions(t *testing.T) { + dir := filepath.Join(t.TempDir(), "logs") + file, err := Get(dir, "2026-07-05") + if err != nil { + t.Fatal(err) + } + + info, err := os.Stat(file.Path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("log file mode = %o, want 600 (owner-only)", got) + } + + dirInfo, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if got := dirInfo.Mode().Perm(); got != 0o700 { + t.Errorf("log dir mode = %o, want 700 (owner-only)", got) + } +} + +func TestUpdateFreezedLog(t *testing.T) { + body := model.NewLog() + body.Freezed = true + if err := Update(t.TempDir(), "2026-07-05", body); !errors.Is(err, ErrFreezed) { + t.Errorf("err = %v, want ErrFreezed", err) + } +} + +func TestInvalidDate(t *testing.T) { + if _, err := Get(t.TempDir(), "not-a-date"); err == nil { + t.Errorf("Get with invalid date should fail") + } +} + +func TestListSortedAndFiltered(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"2026-07-05", "2026-05-01", "2026-06-15"} { + if _, err := Get(dir, name); err != nil { + t.Fatal(err) + } + } + // Files that must be ignored. + for _, name := range []string{"not-a-date.json", "2026-07-05.txt"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + } + + refs, err := List(dir) + if err != nil { + t.Fatal(err) + } + + want := []string{"2026-05-01", "2026-06-15", "2026-07-05"} + if len(refs) != len(want) { + t.Fatalf("refs = %+v, want %v", refs, want) + } + for i, name := range want { + if refs[i].Name != name { + t.Errorf("refs[%d].Name = %q, want %q (ascending by date)", i, refs[i].Name, name) + } + } +} + +func TestListMissingDir(t *testing.T) { + refs, err := List(filepath.Join(t.TempDir(), "no-such-dir")) + if err != nil { + t.Fatal(err) + } + if refs != nil { + t.Errorf("refs = %+v, want nil", refs) + } +} + +func TestRemove(t *testing.T) { + dir := t.TempDir() + if _, err := Get(dir, "2026-07-05"); err != nil { + t.Fatal(err) + } + + refs, err := List(dir) + if err != nil || len(refs) != 1 { + t.Fatalf("refs = %+v, err = %v", refs, err) + } + + if err := Remove(refs[0]); err != nil { + t.Fatalf("Remove should delete the actual file (Deno version bug): %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") + file, err := Stat(dir, "2026-07-05") + if err != nil { + t.Fatal(err) + } + if file == nil { + t.Fatal("legacy 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 new file mode 100644 index 0000000..60e5a8d --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,113 @@ +// 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/). +package model + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "time" +) + +// DateLayout is the file-name date format (yyyy-MM-dd, local time). +const DateLayout = "2006-01-02" + +// isoLayout mirrors JavaScript's Date.toISOString() (UTC, milliseconds). +const isoLayout = "2006-01-02T15:04:05.000Z" + +// Item is a single log entry. The presence of Closed distinguishes a +// task (non-nil) from a memo (nil). +type Item struct { + Hash string `json:"hash"` + Content string `json:"content"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Closed *bool `json:"closed,omitempty"` +} + +// Log is the body of one daily log file. +type Log struct { + Hash string `json:"hash"` + Freezed bool `json:"freezed"` + Items []Item `json:"items"` +} + +// IsTask reports whether the item is a task (has a closed flag). +func (i Item) IsTask() bool { + return i.Closed != nil +} + +// IsClosed reports whether the item is a finished task. +func (i Item) IsClosed() bool { + return i.Closed != nil && *i.Closed +} + +// NewTaskItem creates an open task with a fresh ID and timestamps. +func NewTaskItem(content string) Item { + now := NowISO() + closed := false + return Item{ + Hash: NewID(), + Content: content, + CreatedAt: now, + UpdatedAt: now, + Closed: &closed, + } +} + +// NewMemoItem creates a memo with a fresh ID and timestamps. +func NewMemoItem(content string) Item { + now := NowISO() + return Item{ + Hash: NewID(), + Content: content, + CreatedAt: now, + UpdatedAt: now, + } +} + +// NewLog creates an empty, unfrozen log body. +func NewLog() Log { + return Log{ + Hash: NewID(), + Freezed: false, + Items: []Item{}, + } +} + +// NewID returns a random 8-character hex ID used as item/file hash. +func NewID() string { + buf := make([]byte, 4) + if _, err := rand.Read(buf); err != nil { + // crypto/rand never fails on supported platforms; fall back to time. + return fmt.Sprintf("%08x", time.Now().UnixNano()&0xffffffff) + } + return hex.EncodeToString(buf) +} + +// NowISO returns the current UTC time in JavaScript toISOString() format. +func NowISO() string { + return time.Now().UTC().Format(isoLayout) +} + +// ParseDate strictly parses a yyyy-MM-dd string. +func ParseDate(value string) (time.Time, error) { + t, err := time.ParseInLocation(DateLayout, value, time.Local) + if err != nil { + return time.Time{}, fmt.Errorf("invalid date %q: expected format yyyy-MM-dd", value) + } + return t, nil +} + +// IsDateString reports whether value is a strict yyyy-MM-dd date. +func IsDateString(value string) bool { + _, err := ParseDate(value) + return err == nil +} + +// Today returns the current local date as yyyy-MM-dd, matching the +// daily log file naming. +func Today() string { + return time.Now().Format(DateLayout) +} diff --git a/internal/model/model_test.go b/internal/model/model_test.go new file mode 100644 index 0000000..d1dbb21 --- /dev/null +++ b/internal/model/model_test.go @@ -0,0 +1,106 @@ +package model + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "testing" +) + +func TestLegacyFileRoundTrip(t *testing.T) { + path := filepath.Join("..", "..", "testdata", "legacy-samples", "2026-07-05.json") + original, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read legacy sample: %v", err) + } + + var body Log + if err := json.Unmarshal(original, &body); err != nil { + t.Fatalf("unmarshal legacy 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 !body.Items[1].IsTask() || body.Items[1].IsClosed() { + t.Errorf("item 1 should be an open task: %+v", body.Items[1]) + } + if body.Items[2].IsTask() { + t.Errorf("item 2 should be a memo: %+v", body.Items[2]) + } + + remarshaled, err := json.MarshalIndent(body, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(remarshaled) != string(original) { + t.Errorf("round trip mismatch:\n--- original ---\n%s\n--- remarshaled ---\n%s", original, remarshaled) + } +} + +func TestNewID(t *testing.T) { + pattern := regexp.MustCompile(`^[0-9a-f]{8}$`) + seen := map[string]bool{} + for range 100 { + id := NewID() + if !pattern.MatchString(id) { + t.Fatalf("id %q does not match 8-char hex", id) + } + if seen[id] { + t.Fatalf("duplicated id %q", id) + } + seen[id] = true + } +} + +func TestNewItems(t *testing.T) { + task := NewTaskItem("task content") + if !task.IsTask() || task.IsClosed() { + t.Errorf("new task should be open task: %+v", task) + } + if task.CreatedAt != task.UpdatedAt { + t.Errorf("timestamps should match on creation: %+v", task) + } + + memo := NewMemoItem("memo content") + if memo.IsTask() { + t.Errorf("new memo should not be a task: %+v", memo) + } +} + +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) { + t.Errorf("NowISO() = %q, want JS toISOString format", now) + } +} + +func TestParseDateStrict(t *testing.T) { + valid := []string{"2026-07-05", "2000-01-31"} + for _, value := range valid { + if !IsDateString(value) { + t.Errorf("IsDateString(%q) = false, want true", value) + } + } + + invalid := []string{"", "2026-7-5", "2026/07/05", "not-a-date", "2026-13-01", "20260705"} + for _, value := range invalid { + if IsDateString(value) { + t.Errorf("IsDateString(%q) = true, want false", value) + } + } +} + +func TestNewLogMarshalsEmptyItems(t *testing.T) { + data, err := json.Marshal(NewLog()) + if err != nil { + t.Fatal(err) + } + if want := `"items":[]`; !regexp.MustCompile(regexp.QuoteMeta(want)).Match(data) { + t.Errorf("new log should marshal items as [], got %s", data) + } +} diff --git a/internal/view/view.go b/internal/view/view.go new file mode 100644 index 0000000..e25ac7c --- /dev/null +++ b/internal/view/view.go @@ -0,0 +1,79 @@ +// Package view renders command output. The layout follows the former +// Deno implementation (see testdata/legacy-samples/outputs.md); +// wording equivalence is functional, not character-exact. +package view + +import ( + "fmt" + "io" + "time" + + "github.com/rn404/nippo-cli/internal/model" +) + +const bullet = "-" + +// Header prints a section title surrounded by blank space. +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 { + fmt.Fprintln(w, "There is no body...") + return + } + + if len(tasks) > 0 { + fmt.Fprintln(w, "Task ->") + for _, item := range tasks { + checkbox := "[ ]" + if item.IsClosed() { + checkbox = "[x]" + } + fmt.Fprintf(w, "%s %s %s (%s) %s\n", bullet, checkbox, item.Content, formatTime(item.CreatedAt), item.Hash) + } + } + + 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\n", bullet, item.Content, formatTime(item.CreatedAt), item.Hash) + } + } +} + +// FinishedTask prints the closed task confirmation. +func FinishedTask(w io.Writer, item model.Item) { + fmt.Fprintln(w, "Finished!!") + fmt.Fprintf(w, "> %s (%s)\n", item.Content, formatTime(item.CreatedAt)) +} + +// 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 := " " + if freezed { + freezedMark = "*" + } + fmt.Fprintf(w, "%s %s%s Task: %d (unfinished: %d), Memo: %d\n", + bullet, name, freezedMark, len(tasks), unfinished, len(memos)) +} + +// ListItem prints a single bullet line. +func ListItem(w io.Writer, message string) { + fmt.Fprintf(w, "%s %s\n", bullet, message) +} + +// formatTime renders an ISO timestamp as local HH:mm. +func formatTime(iso string) string { + t, err := time.Parse(time.RFC3339, iso) + if err != nil { + return iso + } + return t.Local().Format("15:04") +} diff --git a/internal/view/view_test.go b/internal/view/view_test.go new file mode 100644 index 0000000..f2476b2 --- /dev/null +++ b/internal/view/view_test.go @@ -0,0 +1,73 @@ +package view + +import ( + "strings" + "testing" + + "github.com/rn404/nippo-cli/internal/model" +) + +func TestItemList(t *testing.T) { + closed := true + open := false + 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}, + } + memos := []model.Item{ + {Hash: "cccc3333", Content: "shrimp looks happy today", CreatedAt: "2026-07-05T08:43:05.073Z"}, + } + + var buf strings.Builder + ItemList(&buf, tasks, memos) + out := buf.String() + + for _, want := range []string{ + "Task ->", + "- [x] buy cabbage (", + ") aaaa1111", + "- [ ] feed the shrimp (", + "Memo ->", + "- shrimp looks happy today (", + ") cccc3333", + } { + if !strings.Contains(out, want) { + t.Errorf("output should contain %q:\n%s", want, out) + } + } +} + +func TestItemListEmpty(t *testing.T) { + var buf strings.Builder + ItemList(&buf, nil, nil) + if !strings.Contains(buf.String(), "There is no body...") { + t.Errorf("empty list output = %q", buf.String()) + } +} + +func TestFileStat(t *testing.T) { + open := false + tasks := []model.Item{{Hash: "a", Closed: &open}} + memos := []model.Item{{Hash: "b"}, {Hash: "c"}} + + var buf strings.Builder + FileStat(&buf, "2026-07-05", false, tasks, memos, 1) + if got, want := buf.String(), "- 2026-07-05 Task: 1 (unfinished: 1), Memo: 2\n"; got != want { + t.Errorf("FileStat = %q, want %q", got, want) + } + + buf.Reset() + FileStat(&buf, "2026-07-05", true, tasks, memos, 1) + if !strings.Contains(buf.String(), "2026-07-05*") { + t.Errorf("freezed mark missing: %q", buf.String()) + } +} + +func TestFinishedTask(t *testing.T) { + var buf strings.Builder + FinishedTask(&buf, model.Item{Content: "buy cabbage", CreatedAt: "2026-07-05T08:43:04.971Z"}) + out := buf.String() + if !strings.Contains(out, "Finished!!") || !strings.Contains(out, "> buy cabbage (") { + t.Errorf("FinishedTask output = %q", out) + } +} diff --git a/src/commands/add.ts b/src/commands/add.ts deleted file mode 100644 index 0439a46..0000000 --- a/src/commands/add.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { getLogFile, updateLogFile } from '../features/logFile.ts'; -import { addItem } from '../features/log.ts'; -import { logDir } from '../features/path.ts'; - -export const addCommand = async ( - options: { memo?: boolean }, - content: string, -): Promise => { - const dir = logDir(); - const { fileName, body: log } = await getLogFile(dir); - const newLog = addItem(log, content, options.memo !== true); - - await updateLogFile( - dir, - fileName, - newLog, - ); -}; diff --git a/src/commands/clear.ts b/src/commands/clear.ts deleted file mode 100644 index c54ea48..0000000 --- a/src/commands/clear.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { STORAGE_PERIOD_DAY } from '../const.ts'; -import { listLogFile } from '../features/logFile.ts'; -import { logDir } from '../features/path.ts'; - -const clearAllFiles = async (logDir: string): Promise => { - const isDeleteAllLogFiles = confirm('Do you want to delete all the files?'); - - if (isDeleteAllLogFiles === false) return; - - const logFiles = await listLogFile(logDir); - if (logFiles.length === 0) { - console.log('There is no log files.'); - return; - } - - logFiles.forEach(async (log) => { - console.log(`Deleted... ${log.fileName} logs.`); - await Deno.remove(log.path); - }); - - console.log('Deleted all files.'); -}; - -const clearOldFiles = async (logDir: string): Promise => { - console.log(` - Delete logs that are past their storage period. ( Storage period: ${ - Math.abs(STORAGE_PERIOD_DAY) - } days ) - `); - - const periodDate = new Date(new Date().setDate(STORAGE_PERIOD_DAY)); - const logFiles = await listLogFile(logDir); - logFiles.forEach(async (log) => { - if ( - new Date(log.fileName).valueOf() < periodDate.valueOf() - ) { - console.log(`Deleted... ${log.fileName} logs.`); - await Deno.remove(log.path); - } - }); -}; - -export const clearCommand = async ( - options: { all?: boolean }, -): Promise => { - const dir = logDir(); - if (options.all === true) { - await clearAllFiles(dir); - return; - } - await clearOldFiles(dir); -}; diff --git a/src/commands/delete.ts b/src/commands/delete.ts deleted file mode 100644 index 80c4fd0..0000000 --- a/src/commands/delete.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { getLogFile, updateLogFile } from '../features/logFile.ts'; -import { deleteItem } from '../features/log.ts'; -import { logDir } from '../features/path.ts'; - -export const deleteCommand = async ( - hash: string, -): Promise => { - const dir = logDir(); - const { fileName, body: log } = await getLogFile(dir); - const newLog = deleteItem(log, hash); - - await updateLogFile( - dir, - fileName, - newLog, - ); -}; diff --git a/src/commands/end.ts b/src/commands/end.ts deleted file mode 100644 index 4fea361..0000000 --- a/src/commands/end.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { getLogFile, updateLogFile } from '../features/logFile.ts'; -import { finishTaskItem } from '../features/log.ts'; -import { generateFinishedTaskItem } from '../features/generate.ts'; -import { logDir } from '../features/path.ts'; - -export const endCommand = async ( - hash: string, -): Promise => { - const dir = logDir(); - const { fileName, body: log } = await getLogFile(dir); - const { log: newLog, finished } = finishTaskItem(log, hash); - - generateFinishedTaskItem(finished); - - await updateLogFile( - dir, - fileName, - newLog, - ); -}; diff --git a/src/commands/list.ts b/src/commands/list.ts deleted file mode 100644 index 904f755..0000000 --- a/src/commands/list.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { FILE_STATS_LIMIT_DAYS } from '../const.ts'; -import { getLogFile, listLogFile, statLogFile } from '../features/logFile.ts'; -import { listItems } from '../features/log.ts'; -import { - generateHeader, - generateItemList, - generateListItem, - generateLogFileStat, -} from '../features/generate.ts'; -import { requiredDateFormatHash } from '../features/hash.ts'; -import { logDir } from '../features/path.ts'; -import { DateString } from '../models/Date.ts'; -import { Log, LogFile } from '../models/LogFile.ts'; - -const viewLogFileStat = (fileName: DateString, log: Log): void => { - const { tasks, memos } = listItems(log); - const unfinishedTaskCount = - tasks.filter((item) => item.closed === false).length; - - generateLogFileStat({ - fileName, - isFreezed: log.freezed, - items: { - tasks, - memos, - }, - unfinishedTaskCount, - }); -}; - -export const listCommand = async ( - options: { all?: boolean; stat?: boolean }, - hash?: string, -): Promise => { - const dir = logDir(); - - if (options.all !== true) { - if (hash !== undefined && requiredDateFormatHash(hash) === true) { - throw new Error('Invalid hash string.'); - } - - const logFile = await statLogFile( - dir, - hash as DateString, - ); - - if (logFile === undefined) { - // TODO(@rn404) handle empty log case - generateHeader(`Today's logs are...`); - console.log('There is no body...'); - return; - } - const { fileName, body: log } = logFile; - - if (options.stat === true) { - if (hash === undefined || hash === '') { - generateHeader(`Today's log stats are...`); - } else { - generateHeader(`Log stats for ${hash} are...`); - } - - viewLogFileStat(fileName, log); - return; - } - - if (hash === undefined || hash === '') { - generateHeader(`Today's logs are...`); - } else { - generateHeader(`Log for ${hash} are...`); - } - generateItemList(listItems(log)); - return; - } - - const listLogFileNames = await listLogFile(dir); - - if (options.stat === true) { - generateHeader( - `View all log statistics. There are ${listLogFileNames.length} total.`, - ); - - if (listLogFileNames.length > FILE_STATS_LIMIT_DAYS) { - const acceptedAllReading = confirm(` - The log was found to be more than ${FILE_STATS_LIMIT_DAYS} days old. - It may take some time to display all of them. - Are you sure you want to view them? - `); - - if (acceptedAllReading === false) return; - } - - const logFiles: Array = await Promise.all( - listLogFileNames.map(async (item) => { - return await getLogFile(dir, item.fileName); - }), - ); - logFiles.forEach((logFile) => - viewLogFileStat(logFile.fileName, logFile.body) - ); - return; - } - - generateHeader(`The logs here are...`); - - listLogFileNames.forEach((logFile) => { - generateListItem(logFile.fileName); - }); -}; diff --git a/src/const.ts b/src/const.ts deleted file mode 100644 index 513ca1d..0000000 --- a/src/const.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const APP_NAME = 'nippo-cli'; -export const VERSION = '0.0.1'; - -export const LOG_DIR = '.log/sava'; -export const LOG_FILE_EXT = 'json'; - -export const FILE_STATS_LIMIT_DAYS = 10; -export const STORAGE_PERIOD_DAY = -30; diff --git a/src/features/generate.ts b/src/features/generate.ts deleted file mode 100644 index 9ab3845..0000000 --- a/src/features/generate.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { formatTime } from '../utils/formatTime.ts'; -import { DateFromISOString } from '../models/Date.ts'; -import { MemoItem } from '../models/MemoItem.ts'; -import { TaskItem } from '../models/TaskItem.ts'; -import { LogFile } from '../models/LogFile.ts'; - -const WORD_SPACER = ' '; -const LIST_BULLET = '-'; - -const generateBreakLine = (): void => { - console.log(); // really? -}; - -const formatTimeString = (item: DateFromISOString): string => { - // return `(${format(new Date(item), 'HH:mm:ss', {})})` - return `(${formatTime(new Date(item))})`; -}; - -const generateTaskListHeader = (): void => { - console.log('Task ->'); -}; - -const generateTaskItem = (item: TaskItem): void => { - console.log( - [ - LIST_BULLET, - item.closed ? '[x]' : '[ ]', - item.content, - formatTimeString(item.createdAt), - item.hash, - ].join(WORD_SPACER), - ); -}; - -const generateMemoListHeader = (): void => { - console.log('Memo ->'); -}; - -const generateMemoItem = (item: MemoItem): void => { - console.log( - [ - LIST_BULLET, - item.content, - formatTimeString(item.createdAt), - item.hash, - ].join(WORD_SPACER), - ); -}; - -export const generateItemList = (contents: { - tasks: Array; - memos: Array; -}) => { - if (contents.tasks.length === 0 && contents.memos.length === 0) { - console.log('There is no body...'); - return; - } - - if (contents.tasks.length > 0) { - generateTaskListHeader(); - contents.tasks.forEach(generateTaskItem); - } - - if (contents.tasks.length > 0 && contents.memos.length > 0) { - generateBreakLine(); - } - - if (contents.memos.length > 0) { - generateMemoListHeader(); - contents.memos.forEach(generateMemoItem); - } -}; - -export const generateFinishedTaskItem = (item: TaskItem): void => { - console.log('Finished!!'); - console.log( - [ - '>', - item.content, - formatTimeString(item.createdAt), - ].join(WORD_SPACER), - ); -}; - -export const generateHeader = (title: string): void => { - console.log(` - ${title} - `); -}; - -export const generateLogFileStat = (logFileInfo: { - fileName: LogFile['fileName']; - isFreezed: LogFile['body']['freezed']; - items: { - tasks: Array; - memos: Array; - }; - unfinishedTaskCount: number; -}): void => { - const isFreezedMark = logFileInfo.isFreezed === true ? '*' : ' '; - - console.log( - [ - LIST_BULLET, - logFileInfo.fileName + isFreezedMark, - `Task: ${logFileInfo.items.tasks.length}`, - `(unfinished: ${logFileInfo.unfinishedTaskCount}),`, - `Memo: ${logFileInfo.items.memos.length}`, - ].join(WORD_SPACER), - ); -}; - -export const generateListItem = (messages: string | Array): void => { - console.log( - [LIST_BULLET].concat(messages).join(WORD_SPACER), - ); -}; diff --git a/src/features/hash.ts b/src/features/hash.ts deleted file mode 100644 index 9c90891..0000000 --- a/src/features/hash.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { DateFromISOString } from '../models/Date.ts'; - -export const requiredDateFormatHash = (hash: string): boolean => { - if (/(\d{4})-(\d{1,2})-(\d{1,2})/.test(hash) === false) return true; - - return Number.isNaN(new Date(hash).getDate()); -}; - -// Sort by date in descending order -export const compareDatesInDescent = ( - first: DateFromISOString, - second: DateFromISOString, -) => { - return new Date(first).valueOf() - new Date(second).valueOf(); -}; diff --git a/src/features/log.ts b/src/features/log.ts deleted file mode 100644 index 3a02ec6..0000000 --- a/src/features/log.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { MemoItem } from '../models/MemoItem.ts'; -import { TaskItem } from '../models/TaskItem.ts'; -import { convertToLogItem, Item, Log } from '../models/LogFile.ts'; - -export const addItem = ( - log: Log, - content: Item['content'], - isTask: boolean, -): Log => { - if (log.freezed === true) { - throw new Error('This log file is freezed. No updates.'); - } - - const newItem = isTask === true - ? new TaskItem(content) - : new MemoItem(content); - - log.items.push(convertToLogItem(newItem)); - - return log; -}; - -export const deleteItem = ( - log: Log, - hash: Item['hash'], -): Log => { - const newItems = log.items.filter((item) => item.hash !== hash); - log.items = newItems; - - return log; -}; - -export const finishTaskItem = ( - log: Log, - hash: Item['hash'], -): { - log: Log; - finished: TaskItem; -} => { - const index = log.items.findIndex((item) => item.hash === hash); - const targetItem = log.items[index]; - - if (targetItem === undefined) { - throw new Error('Target item is not founded.'); - } - - if (targetItem.closed === undefined) { - throw new Error('Target item is not task.'); - } - - if (targetItem.closed === true) { - throw new Error('Target item is already finished.'); - } - - const taskItem = new TaskItem( - targetItem.content, - targetItem.createdAt, - targetItem.updatedAt, - targetItem.closed, - targetItem.hash, - ); - taskItem.close(); - - log.items.splice(index, 1, convertToLogItem(taskItem)); - - return { - log, - finished: taskItem, - }; -}; - -export const listItems = ( - log: Log, -): { tasks: Array; memos: Array } => { - const tasks: Array = []; - const memos: Array = []; - - log.items.forEach((item) => { - if (item.closed !== undefined) { - tasks.push( - new TaskItem( - item.content, - item.createdAt, - item.updatedAt, - item.closed, - item.hash, - ), - ); - } else { - memos.push( - new MemoItem( - item.content, - item.createdAt, - item.updatedAt, - item.hash, - ), - ); - } - }); - - tasks.sort((a, b) => { - return new Date(a.createdAt).valueOf() - new Date(b.createdAt).valueOf(); - }); - memos.sort((a, b) => { - return new Date(a.createdAt).valueOf() - new Date(b.createdAt).valueOf(); - }); - - return { - tasks, - memos, - }; -}; diff --git a/src/features/logFile.ts b/src/features/logFile.ts deleted file mode 100644 index 4d4520d..0000000 --- a/src/features/logFile.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { walk } from '@std/fs'; -import { ensureFile } from '@std/fs'; -import { createHash } from 'node:crypto'; -import type { DateString } from '../models/Date.ts'; -import type { LogFile } from '../models/LogFile.ts'; -import { isDateString } from '../models/Date.ts'; -import { LogFileNameFactory } from '../models/factory/LogFileName.ts'; -import { compareDatesInDescent } from './hash.ts'; -import { pathResolve } from './path.ts'; - -const LOG_FILE_INDENT_SPACE = 2; - -const logFileNameFactory = new LogFileNameFactory(Date); - -export const statLogFile = async ( - logDir: string, - targetDay?: DateString, /* yyyy-MM-dd */ -): Promise => { - const targetFileName = logFileNameFactory.create(targetDay); - - const targetFileFullPath = pathResolve([ - logDir, - targetFileName.withExtension, - ]); - - try { - const stat: Deno.FileInfo = await Deno.lstat(targetFileFullPath); - - if (stat.isFile === false) { - throw new Error('Target log file is already exists and not file.'); - } - - const targetLog: LogFile['body'] = JSON.parse( - await Deno.readTextFile(targetFileFullPath), - ); - - return { - path: targetFileFullPath, - fileName: targetFileName.name, - body: targetLog, - }; - } catch (error: unknown) { - if (error instanceof Deno.errors.NotFound) { - return undefined; - } - throw error; - } -}; - -export const getLogFile = async ( - logDir: string, - targetDay?: DateString, /* yyyy-MM-dd */ -): Promise => { - const targetFileName = logFileNameFactory.create(targetDay); - - const targetFileFullPath = pathResolve([ - logDir, - targetFileName.withExtension, - ]); - - // Check if a log file has been created. - try { - const stat = await Deno.lstat(targetFileFullPath); - if (stat.isFile === false) { - throw new Error('Target log file is already exists and not file.'); - } - } catch (error) { - if (error instanceof Deno.errors.NotFound) { - // create new file - await ensureFile(targetFileFullPath); - const newLog: LogFile['body'] = { - hash: createHash('md5').update(new Date().toString()).toString(), - freezed: false, - items: [], - }; - updateLogFile(logDir, targetFileName.name, newLog); - - // return information on file that new created. - return { - path: targetFileFullPath, - fileName: targetFileName.name, - body: newLog, - }; - } else { - throw error; - } - } - - const targetLog: LogFile['body'] = JSON.parse( - await Deno.readTextFile(targetFileFullPath), - ); - - // return information on file that already exists. - return { - path: targetFileFullPath, - fileName: targetFileName.name, - body: targetLog, - }; -}; - -export const updateLogFile = async ( - logDir: string, - targetDay: DateString, /* yyyy-MM-dd */ - body: LogFile['body'], -): Promise => { - if (body.freezed === true) { - throw new Error('This log file is freezed. No updates.'); - } - const targetFileName = logFileNameFactory.create(targetDay); - - const targetFileFullPath = pathResolve([ - logDir, - targetFileName.withExtension, - ]); - - const newLog = new TextEncoder().encode( - JSON.stringify(body, null, LOG_FILE_INDENT_SPACE), - ); - - await Deno.writeFile(targetFileFullPath, newLog); - - return { - path: targetFileFullPath, - fileName: targetDay, - body, - }; -}; - -export const listLogFile = async ( - logDir: string, -): Promise< - Array> -> => { - const listLog: Array | undefined> = []; - const files = walk(logDir, { - maxDepth: 1, - includeDirs: false, - exts: ['json'], - }); - - for await (const file of files) { - const logFileDate = file.name.split('.')[0]; - const logFileName = logFileNameFactory.create(logFileDate as DateString); - - listLog.push( - isDateString(logFileDate) - ? { - path: pathResolve([ - logDir, - file.path, - ]), - fileName: logFileName.name, - } - : undefined, - ); - } - - return listLog - .filter((item): item is Pick => - item !== undefined - ) - .sort((a, b) => compareDatesInDescent(a.fileName, b.fileName)); -}; - -// export const deleteLogFile = ( -// logDir: string, -// targetDay: DateString, /* yyyy-MM-dd */ -// ): Pick => {}; diff --git a/src/features/path.ts b/src/features/path.ts deleted file mode 100644 index bc8400e..0000000 --- a/src/features/path.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { join } from '@std/path'; -import { LOG_DIR } from '../const.ts'; -import { homeDir } from '../utils/homeDir.ts'; - -export const pathResolve = (path: string[]): string => { - return join(...(path as [string, ...string[]])); -}; - -export const rootDir = (): string => { - const root = homeDir(); - return root ?? Deno.cwd(); -}; - -export const logDir = (): string => { - return join( - rootDir(), - LOG_DIR, - ); -}; diff --git a/src/models/Date.ts b/src/models/Date.ts deleted file mode 100644 index b656db5..0000000 --- a/src/models/Date.ts +++ /dev/null @@ -1,24 +0,0 @@ -// date of iso format -export type DateFromISOString = string; - -type ItemHash = string; -type ItemIndex = number; -type DateYear = string; /* yyyy */ -type DateMonth = string; /* MM, zero pad */ -type DateDay = string; /* dd, zero pad */ - -export type HashDateString = - | `${ItemHash}{${ItemIndex}}` - | `${DateYear}-${DateMonth}-${DateDay}{${ItemIndex}}`; - -/** - * Format is `yyyy-MM-dd`. - * This is because it is easy to convert when using new Date(). - */ -export type DateString = `${DateYear}-${DateMonth}-${DateDay}`; - -export const isDateString = (value?: string): value is DateString => { - if (value === undefined) return false; - // TODO(@rn404) regexp - return Number.isNaN(new Date(value).getDate()) === false; -}; diff --git a/src/models/LogFile.ts b/src/models/LogFile.ts deleted file mode 100644 index ea0c7bb..0000000 --- a/src/models/LogFile.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { DateFromISOString, DateString } from './Date.ts'; -import { MemoItem } from './MemoItem.ts'; -import { TaskItem } from './TaskItem.ts'; - -export interface LogFile { - path: string; // File full path includes file name - fileName: DateString; // File name exclude extension - body: Log; -} - -export interface Item { - hash: string; - createdAt: DateFromISOString; - updatedAt: DateFromISOString; - content: string; - closed?: boolean; -} - -export interface Log { - hash: string; - freezed: boolean; - items: Array; -} - -export const convertToLogItem = (item: MemoItem | TaskItem): Item => { - if (item instanceof MemoItem) { - return { - hash: item.hash, - content: item.content, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - }; - } else if (item instanceof TaskItem) { - return { - hash: item.hash, - content: item.content, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - closed: item.closed, - }; - } - - throw new Error('item is unspecified.'); -}; diff --git a/src/models/LogFileName.ts b/src/models/LogFileName.ts deleted file mode 100644 index 284b360..0000000 --- a/src/models/LogFileName.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { LOG_FILE_EXT } from '../const.ts'; -import { DateString } from './Date.ts'; - -export class LogFileName { - public name: DateString; - public withExtension: `${DateString}.${typeof LOG_FILE_EXT}`; - - constructor(date: DateString) { - this.name = date; - this.withExtension = `${date}.${LOG_FILE_EXT}`; - } -} diff --git a/src/models/MemoItem.ts b/src/models/MemoItem.ts deleted file mode 100644 index 21be2b4..0000000 --- a/src/models/MemoItem.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { createHash } from 'node:crypto'; -import { DateFromISOString } from './Date.ts'; - -export class MemoItem { - public readonly hash: string; - public readonly createdAt: DateFromISOString; - public updatedAt: DateFromISOString; - - constructor( - public content: string, - createdAt?: DateFromISOString, - updateAt?: DateFromISOString, - hash?: string, - ) { - const currentTimeStamp = new Date().toISOString(); - this.createdAt = createdAt ?? currentTimeStamp; - this.updatedAt = updateAt ?? currentTimeStamp; - this.hash = hash ?? createHash('md5') - .update(this.createdAt.toString()).toString(); - } - - public updateContent(newVal: string) { - this.content = newVal; - this.updatedAt = new Date().toISOString(); - } -} - -export const createMemoItem = ( - content: string, - createdAt: DateFromISOString, - updatedAt: DateFromISOString, -): MemoItem => { - const hash = createHash('md5').update(createdAt.toString()).toString(); - return new MemoItem( - content, - createdAt, - updatedAt, - hash, - ); -}; diff --git a/src/models/TaskItem.ts b/src/models/TaskItem.ts deleted file mode 100644 index ae17cfe..0000000 --- a/src/models/TaskItem.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { createHash } from 'node:crypto'; -import { DateFromISOString } from './Date.ts'; - -export class TaskItem { - public readonly hash: string; - public readonly createdAt: DateFromISOString; - public updatedAt: DateFromISOString; - public closed: boolean; - - constructor( - public content: string, - createdAt?: DateFromISOString, - updatedAt?: DateFromISOString, - closed?: boolean, - hash?: string, - ) { - const currentTimeStamp = new Date().toISOString(); - this.createdAt = createdAt ?? currentTimeStamp; - this.updatedAt = updatedAt ?? currentTimeStamp; - this.hash = hash ?? createHash('md5') - .update(this.createdAt.toString()).toString(); - this.closed = closed ?? false; - } - - public updateContent(newVal: string) { - if (this.closed === true) { - return; - } - - this.content = newVal; - this.updatedAt = new Date().toISOString(); - } - - public close() { - if (this.closed === true) { - return; - } - - this.closed = true; - this.updatedAt = new Date().toISOString(); - } -} - -export const createTaskItem = ( - content: string, - createdAt: DateFromISOString, - updatedAt: DateFromISOString, - closed: boolean, -): TaskItem => { - const hash = createHash('md5').update(createdAt.toString()).toString(); - return new TaskItem( - content, - createdAt, - updatedAt, - closed, - hash, - ); -}; diff --git a/src/models/factory/LogFileName.ts b/src/models/factory/LogFileName.ts deleted file mode 100644 index b25fcb9..0000000 --- a/src/models/factory/LogFileName.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { formatDate } from '../../utils/formatDate.ts'; -import { DateString } from '../Date.ts'; -import { LogFileName } from '../LogFileName.ts'; - -export class LogFileNameFactory { - #date: DateConstructor; - - constructor(dateConstructor: DateConstructor) { - this.#date = dateConstructor; - } - - private formatToDateString(date: Date): DateString { - return formatDate(date) as DateString; - } - - public create(date?: Date): LogFileName; - public create(dateString?: DateString): LogFileName; - public create(target?: DateString | Date): LogFileName { - if (target === undefined) { - const date = new this.#date(); - return new LogFileName(this.formatToDateString(date)); - } - - if (target instanceof Date) { - return new LogFileName(this.formatToDateString(target)); - } - - const date = new this.#date(target); - return new LogFileName(this.formatToDateString(date)); - } -} diff --git a/src/models/factory/LogFileName_test.ts b/src/models/factory/LogFileName_test.ts deleted file mode 100644 index 9064b26..0000000 --- a/src/models/factory/LogFileName_test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { assertEquals } from '@std/assert'; -import { LogFileNameFactory } from './LogFileName.ts'; - -class MockDateConstructor { - constructor(value?: string | Date) { - if (value === undefined) { - return new Date('2022-08-30'); - } - return new Date(value); - } -} -const factory = new LogFileNameFactory(MockDateConstructor as DateConstructor); - -Deno.test('target value is undefined', () => { - const actual = factory.create(); - - assertEquals(actual.name, '2022-08-30'); -}); - -Deno.test('target value is typeof DateString', () => { - const actual = factory.create('2022-07-31'); - - assertEquals(actual.name, '2022-07-31'); -}); - -Deno.test('target value is typeof Date', () => { - const targetDate = new Date('2022-04-04'); - const actual = factory.create(targetDate); - - assertEquals(actual.name, '2022-04-04'); -}); diff --git a/src/sava.ts b/src/sava.ts deleted file mode 100644 index 89096f1..0000000 --- a/src/sava.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Command, HelpCommand } from 'cliffy'; -import { APP_NAME, VERSION } from './const.ts'; -import { addCommand } from './commands/add.ts'; -import { endCommand } from './commands/end.ts'; -import { deleteCommand } from './commands/delete.ts'; -import { listCommand } from './commands/list.ts'; -import { clearCommand } from './commands/clear.ts'; - -await new Command() - .name(APP_NAME) - .version(VERSION) - .default('help') - .command( - 'add ', - new Command() - .option( - '-m, --memo', - 'Add contents like memo item.', - ) - .description('Add contents to nippo log.') - .action(async (options, contents) => { - await addCommand(options, contents); - }), - ) - .command( - 'end ', - new Command() - .description('end to task.') - .action(async (_options, hash) => { - await endCommand(hash); - }), - ) - .command( - 'del ', - new Command() - .description('delete task.') - .action(async (_options, hash) => { - await deleteCommand(hash); - }), - ) - .command( - 'list [hash:string]', - new Command() - .description('list all logs.') - .option( - '-a, --all', - 'show all logs', - ) - .option( - '-s, --stat', - 'show summary of list', - ) - .action(async (options, hash) => { - await listCommand(options, hash); - }), - ) - .command( - 'clear', - new Command() - .description('delete log') - .option( - '-a, --all', - 'clear all logs', - ) - .action(async (options) => { - await clearCommand(options); - }), - ) - .command( - 'help', - new HelpCommand(), - ) - .parse(Deno.args); diff --git a/src/utils/formatDate.ts b/src/utils/formatDate.ts deleted file mode 100644 index f10e6bc..0000000 --- a/src/utils/formatDate.ts +++ /dev/null @@ -1,5 +0,0 @@ -const formatDate = (date: Date): string => { - return new Intl.DateTimeFormat('en-CA').format(date); -}; - -export { formatDate }; diff --git a/src/utils/formatTime.ts b/src/utils/formatTime.ts deleted file mode 100644 index 021e704..0000000 --- a/src/utils/formatTime.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Replaced with Intl.DateTimeFormat -const formatTime = (date: Date): string => { - return new Intl.DateTimeFormat('en-US', { - hour: '2-digit', - minute: '2-digit', - hour12: false, - }).format(date); -}; - -export { formatTime }; diff --git a/src/utils/homeDir.ts b/src/utils/homeDir.ts deleted file mode 100644 index 06747c6..0000000 --- a/src/utils/homeDir.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Replaced with native APIs -const homeDir = (): string | undefined => { - return Deno.env.get('HOME') || Deno.env.get('USERPROFILE'); -}; - -export { homeDir }; diff --git a/testdata/legacy-samples/2026-07-05.json b/testdata/legacy-samples/2026-07-05.json new file mode 100644 index 0000000..f751d72 --- /dev/null +++ b/testdata/legacy-samples/2026-07-05.json @@ -0,0 +1,26 @@ +{ + "hash": "[object Object]", + "freezed": false, + "items": [ + { + "hash": "[object Object]", + "content": "buy cabbage", + "createdAt": "2026-07-05T08:43:04.971Z", + "updatedAt": "2026-07-05T08:44:12.345Z", + "closed": true + }, + { + "hash": "[object Object]", + "content": "feed the shrimp", + "createdAt": "2026-07-05T08:43:05.026Z", + "updatedAt": "2026-07-05T08:43:05.026Z", + "closed": false + }, + { + "hash": "[object Object]", + "content": "shrimp looks happy today", + "createdAt": "2026-07-05T08:43:05.073Z", + "updatedAt": "2026-07-05T08:43:05.073Z" + } + ] +} \ No newline at end of file diff --git a/testdata/legacy-samples/outputs.md b/testdata/legacy-samples/outputs.md new file mode 100644 index 0000000..457eec9 --- /dev/null +++ b/testdata/legacy-samples/outputs.md @@ -0,0 +1,107 @@ +# Deno 版 CLI の出力サンプル(Phase 0 採取) + +採取日: 2026-07-05 / Deno 2.4.3 / `HOME` を一時ディレクトリに向けて実測。 +Go 版は表示テキストの完全一致を目指さず、**表示される情報の過不足がないこと**を基準とする(計画書「実装ポリシー」参照)。 + +## add / add -m + +出力なし(正常終了、exit 0)。`~/.log/sava/yyyy-MM-dd.json` に追記される。 + +## list(当日) + +``` + Today's logs are... + +Task -> +- [ ] buy cabbage (17:43) [object Object] +- [ ] feed the shrimp (17:43) [object Object] + +Memo -> +- shrimp looks happy today (17:43) [object Object] +``` + +- 時刻は `(HH:mm)` のローカルタイム 24 時間表記 +- 行形式: `- [x| ] content (HH:mm) hash`(メモはチェックボックスなし) +- 末尾の hash は既存バグにより全アイテム `[object Object]` +- アイテムが 0 件のときは `There is no body...` + +## list + +``` + Log for 2026-07-05 are... +``` + +以降は list と同じ。日付でない引数はエラー(現行はスタックトレース、exit 1)。 + +## list -s(当日の統計) + +``` + Today's log stats are... + +- 2026-07-05 Task: 2 (unfinished: 2), Memo: 1 +``` + +- 日付の直後の `*` は freezed マーク(freezed 時のみ) + +## list -a(ファイル一覧、日付昇順) + +``` + The logs here are... + +- 2026-05-01 +- 2026-07-05 +``` + +## list -a -s(全ファイル統計、10 件超で確認プロンプト) + +``` + View all log statistics. There are 2 total. + +- 2026-05-01 Task: 0 (unfinished: 0), Memo: 0 +- 2026-07-05 Task: 0 (unfinished: 0), Memo: 0 +``` + +## end + +``` +Finished!! +> buy cabbage (17:43) +``` + +- 最初に一致した未完了タスクを閉じる(既存バグにより hash は全件同一のため、実質先頭のタスク) + +## del + +出力なし。hash が一致する**全アイテム**を削除(既存バグにより実質全件削除)。 + +## clear(30 日超過分の削除) + +``` + Delete logs that are past their storage period. ( Storage period: 30 days ) + +Deleted... 2026-05-01 logs. +``` + +**注意(既存バグ)**: 上記メッセージの後、パス二重化により `Deno.remove` が +NotFound で失敗し、**実際にはファイルは削除されない**(`clear -a` も同様)。 +Go 版では正しく削除される(意図した修正)。 + +## help(引数なし実行も同じ) + +``` + Usage: nippo-cli + Version: 0.0.1 + + Options: -h, --help / -V, --version + Commands: add / end / del / list [hash] / clear / help [command] +``` + +- 表示名が `nippo-cli` になっている(Go 版では `sava` に統一、v0.1.0) + +## 保存 JSON フォーマット + +`2026-07-05.json` を参照。キー順は `hash, freezed, items` / +アイテムは `hash, content, createdAt, updatedAt(, closed)`。 +`closed` キーの有無でタスク / メモを判別。インデント 2 スペース、末尾改行なし。 +`createdAt` / `updatedAt` は UTC の ISO 8601(ミリ秒 3 桁 + `Z`)。 +ファイル名の日付はローカルタイム基準。