├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── sonarqube.yml │ └── tests.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── autocomplete ├── bash_autocomplete ├── powershell_autocomplete.ps1 └── zsh_autocomplete ├── bin └── .gitkeep ├── cache.go ├── cmd └── sncli │ ├── add.go │ ├── add_test.go │ ├── debug.go │ ├── delete.go │ ├── edit.go │ ├── export.go │ ├── get.go │ ├── healthcheck.go │ ├── item.go │ ├── main.go │ ├── main_test.go │ ├── note.go │ ├── note_test.go │ ├── register.go │ ├── resync.go │ ├── session.go │ ├── stats.go │ ├── tag.go │ ├── tag_test.go │ ├── task.go │ ├── wipe.go │ └── wipe_test.go ├── constants.go ├── debug.go ├── debug_test.go ├── docs └── CHANGELOG.md ├── export.go ├── export_test.go ├── go.mod ├── go.sum ├── healthcheck.go ├── helpers.go ├── main.go ├── main_test.go ├── note.go ├── note_test.go ├── register.go ├── settings.go ├── sonar-project.properties ├── stats.go ├── stats_test.go ├── sync.go ├── tag.go ├── tag_test.go ├── tasks.go ├── tasks_small_test.go └── test_data.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master, main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master, main ] 20 | schedule: 21 | - cron: '16 23 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'go' ] 36 | 37 | steps: 38 | - name: Checkout repository 39 | uses: actions/checkout@v4 40 | 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v3 43 | with: 44 | languages: ${{ matrix.language }} 45 | 46 | - name: Autobuild 47 | uses: github/codeql-action/autobuild@v3 48 | 49 | - name: Perform CodeQL Analysis 50 | uses: github/codeql-action/analyze@v3 51 | -------------------------------------------------------------------------------- /.github/workflows/sonarqube.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | name: sonarqube 3 | jobs: 4 | sonarcloud: 5 | name: SonarCloud 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | with: 10 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 11 | - name: SonarCloud Scan 12 | uses: SonarSource/sonarcloud-github-action@master 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test sn-cli 2 | 3 | on: 4 | push: 5 | branches: [ master, main ] 6 | pull_request: 7 | branches: [ master, main ] 8 | jobs: 9 | test: 10 | concurrency: 11 | group: test 12 | cancel-in-progress: true 13 | strategy: 14 | max-parallel: 1 15 | matrix: 16 | os: [ubuntu-latest, macos-latest, windows-latest] 17 | runs-on: ${{ matrix.os }} 18 | steps: 19 | - 20 | name: Checkout 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | - 25 | name: Set up Go 26 | uses: actions/setup-go@v5.5.0 27 | with: 28 | go-version: 1.24 29 | - 30 | name: Tests 31 | run: | 32 | go mod tidy 33 | go get github.com/axw/gocov/gocov 34 | go get github.com/AlekSi/gocov-xml 35 | go install github.com/axw/gocov/gocov 36 | go install github.com/AlekSi/gocov-xml 37 | go test -cover -v -failfast -p 1 $(go list ./...) -coverprofile cover.out 38 | gocov convert cover.out | gocov-xml > coverage.xml 39 | if: runner.os != 'Windows' 40 | env: 41 | SN_SERVER: ${{ secrets.SN_SERVER }} 42 | SN_EMAIL: ${{ secrets.SN_EMAIL }} 43 | SN_PASSWORD: ${{ secrets.SN_PASSWORD }} 44 | - 45 | name: Codacy Coverage Reporter 46 | uses: codacy/codacy-coverage-reporter-action@v1.3.0 47 | with: 48 | coverage-reports: coverage.xml 49 | if: runner.os != 'Windows' 50 | env: 51 | CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }} 52 | - 53 | name: Windows Tests 54 | run: | 55 | go mod tidy 56 | go test -v -failfast -p 1 $(go list ./...) 57 | if: runner.os != 'Windows' 58 | env: 59 | SN_SERVER: ${{ secrets.SN_SERVER }} 60 | SN_EMAIL: ${{ secrets.SN_EMAIL }} 61 | SN_PASSWORD: ${{ secrets.SN_PASSWORD }} 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Go Binary 9 | *.gob 10 | *.json 11 | 12 | # Test binary, build with `go test -c` 13 | *.test 14 | coverage.txt 15 | *.tmp 16 | 17 | # Output of the go coverage tool, specifically when used with LiteIDE 18 | *.out 19 | 20 | # goland 21 | .idea/ 22 | 23 | # local build 24 | .local_dist 25 | bin/* 26 | !bin/.gitkeep 27 | 28 | # github release build 29 | dist 30 | 31 | # vscode 32 | .vscode/ 33 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # GoReleaser configuration - v2 syntax 2 | # Documentation: https://goreleaser.com 3 | 4 | version: 2 5 | 6 | project_name: sn-cli 7 | 8 | env: 9 | - GO111MODULE=on 10 | - GOPROXY=https://proxy.golang.org 11 | - CGO_ENABLED=0 12 | 13 | before: 14 | hooks: 15 | - make clean 16 | - go mod tidy 17 | - go mod download 18 | 19 | # Universal macOS binaries 20 | universal_binaries: 21 | - id: sn-macos-universal 22 | ids: 23 | - sn-macos-amd64 24 | - sn-macos-arm64 25 | name_template: "sn" 26 | replace: true 27 | mod_timestamp: "{{ .CommitTimestamp }}" 28 | # Disable gon signing for now - can be enabled when properly configured 29 | # hooks: 30 | # post: | 31 | # sh -c 'cat < /tmp/sn-cli-gon-universal.hcl 32 | # source = ["./dist/sn-macos-universal_darwin_all/sn"] 33 | # bundle_id = "uk.co.lessknown.sn-cli" 34 | # apple_id { 35 | # username = "jon@lessknown.co.uk" 36 | # password = "@env:AC_PASSWORD" 37 | # } 38 | # sign { 39 | # application_identity = "Developer ID Application: Jonathan Hadfield (VBZY8FBYR5)" 40 | # } 41 | # zip { 42 | # output_path = "./dist/sn-cli_Darwin_all.zip" 43 | # } 44 | # EOF 45 | # gon /tmp/sn-cli-gon-universal.hcl 46 | # ' 47 | 48 | builds: 49 | - id: sn-macos-amd64 50 | main: ./cmd/sncli/ 51 | binary: sn 52 | goos: 53 | - darwin 54 | goarch: 55 | - amd64 56 | flags: 57 | - -trimpath 58 | ldflags: 59 | - -s -w 60 | - -X main.version={{ .Version }} 61 | - -X main.sha={{ .ShortCommit }} 62 | - -X main.buildDate={{ .Date }} 63 | - -X main.tag={{ .Tag }} 64 | env: 65 | - CGO_ENABLED=0 66 | 67 | - id: sn-macos-arm64 68 | main: ./cmd/sncli/ 69 | binary: sn 70 | goos: 71 | - darwin 72 | goarch: 73 | - arm64 74 | flags: 75 | - -trimpath 76 | ldflags: 77 | - -s -w 78 | - -X main.version={{ .Version }} 79 | - -X main.sha={{ .ShortCommit }} 80 | - -X main.buildDate={{ .Date }} 81 | - -X main.tag={{ .Tag }} 82 | env: 83 | - CGO_ENABLED=0 84 | 85 | - id: sn-linux-windows 86 | main: ./cmd/sncli/ 87 | binary: sn 88 | goos: 89 | - linux 90 | - windows 91 | - freebsd 92 | - openbsd 93 | goarch: 94 | - amd64 95 | - arm64 96 | - 386 97 | goarm: 98 | - "6" 99 | - "7" 100 | ignore: 101 | - goos: windows 102 | goarch: arm64 103 | - goos: freebsd 104 | goarch: arm64 105 | - goos: openbsd 106 | goarch: arm64 107 | flags: 108 | - -trimpath 109 | ldflags: 110 | - -s -w 111 | - -X main.version={{ .Version }} 112 | - -X main.sha={{ .ShortCommit }} 113 | - -X main.buildDate={{ .Date }} 114 | - -X main.tag={{ .Tag }} 115 | env: 116 | - CGO_ENABLED=0 117 | 118 | archives: 119 | - id: default 120 | name_template: >- 121 | {{ .ProjectName }}_ 122 | {{- title .Os }}_ 123 | {{- if eq .Arch "amd64" }}x86_64 124 | {{- else if eq .Arch "386" }}i386 125 | {{- else }}{{ .Arch }}{{ end }} 126 | {{- if .Arm }}v{{ .Arm }}{{ end }} 127 | builds: 128 | - sn-linux-windows 129 | format_overrides: 130 | - goos: windows 131 | format: zip 132 | files: 133 | - LICENSE 134 | - README.md 135 | - CHANGELOG.md 136 | 137 | - id: macos 138 | name_template: "{{ .ProjectName }}_Darwin_universal" 139 | builds: 140 | - sn-macos-universal 141 | files: 142 | - LICENSE 143 | - README.md 144 | - CHANGELOG.md 145 | 146 | release: 147 | github: 148 | owner: jonhadfield 149 | name: sn-cli 150 | 151 | prerelease: auto 152 | name_template: "v{{ .Version }}" 153 | 154 | header: | 155 | ## sn-cli v{{ .Version }} 156 | 157 | Standard Notes CLI - Manage your notes from the terminal 158 | 159 | footer: | 160 | --- 161 | **Full documentation:** https://github.com/jonhadfield/sn-cli 162 | 163 | **Installation:** See README for platform-specific instructions 164 | 165 | extra_files: 166 | - glob: ./dist/sn-cli_Darwin*.zip 167 | 168 | announce: 169 | skip: false 170 | 171 | discord: 172 | enabled: false 173 | 174 | slack: 175 | enabled: false 176 | 177 | telegram: 178 | enabled: false 179 | 180 | snapshot: 181 | version_template: "{{ .Tag }}-next" 182 | 183 | changelog: 184 | use: github 185 | sort: asc 186 | abbrev: 7 187 | 188 | groups: 189 | - title: "✨ Features" 190 | regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' 191 | order: 0 192 | - title: "🐛 Bug Fixes" 193 | regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' 194 | order: 1 195 | - title: "📚 Documentation" 196 | regexp: '^.*?docs(\([[:word:]]+\))??!?:.+$' 197 | order: 2 198 | - title: "🧹 Chores" 199 | regexp: '^.*?chore(\([[:word:]]+\))??!?:.+$' 200 | order: 3 201 | - title: "Other Changes" 202 | order: 999 203 | 204 | filters: 205 | include: 206 | - "^feat" 207 | - "^fix" 208 | - "^docs" 209 | - "^chore" 210 | - "^perf" 211 | - "^refactor" 212 | - "^style" 213 | - "^test" 214 | exclude: 215 | - "^Merge" 216 | - "^merge" 217 | - "typo" 218 | - "^wip" 219 | - "^WIP" 220 | 221 | checksum: 222 | name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" 223 | algorithm: sha256 224 | extra_files: 225 | - glob: ./dist/sn-cli_Darwin*.zip 226 | 227 | sboms: 228 | - id: source 229 | artifacts: source 230 | documents: 231 | - "{{ .ProjectName }}_{{ .Version }}_sbom.spdx.json" 232 | 233 | # Code signing for macOS (disabled - uncomment when cosign is configured) 234 | # signs: 235 | # - cmd: cosign 236 | # artifacts: checksum 237 | # output: true 238 | # args: 239 | # - "sign-blob" 240 | # - "--key" 241 | # - "cosign.key" 242 | # - "--output-signature" 243 | # - "${artifact}.sig" 244 | # - "${artifact}" 245 | # - "--yes" 246 | # env: 247 | # - COSIGN_PASSWORD={{ .Env.COSIGN_PASSWORD }} 248 | 249 | # Docker configuration (optional - uncomment when Docker support is needed) 250 | # dockers: 251 | # - skip_push: true 252 | # image_templates: 253 | # - "ghcr.io/jonhadfield/{{ .ProjectName }}:{{ .Version }}" 254 | # - "ghcr.io/jonhadfield/{{ .ProjectName }}:latest" 255 | # dockerfile: Dockerfile 256 | # build_flag_templates: 257 | # - "--pull" 258 | # - "--label=org.opencontainers.image.created={{ .Date }}" 259 | # - "--label=org.opencontainers.image.title={{ .ProjectName }}" 260 | # - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 261 | # - "--label=org.opencontainers.image.version={{ .Version }}" 262 | 263 | # Metadata 264 | metadata: 265 | mod_timestamp: "{{ .CommitTimestamp }}" 266 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.3.5] - 2024-01-08 8 | 9 | ### Fixed 10 | - Fix conflict warning handling 11 | - Minor code simplification 12 | 13 | ### Added 14 | - Helper tests 15 | 16 | ## [0.3.4] - 2024-01-07 17 | 18 | ### Fixed 19 | - Fix command completion and update instructions 20 | 21 | ## [0.3.3] - 2024-01-07 22 | 23 | ### Added 24 | - Add `task` command for management of Checklists and Advanced Checklists 25 | 26 | ## [0.3.2] - 2024-01-06 27 | 28 | ### Fixed 29 | - Bug fixes and sync speed increases 30 | 31 | ## [0.3.1] - 2023-12-20 32 | 33 | ### Improved 34 | - Various output improvements, including stats 35 | 36 | ## [0.3.0] - 2023-12-14 37 | 38 | ### Fixed 39 | - Bug fixes and item schema tests 40 | 41 | ## [0.2.8] - 2023-12-07 42 | 43 | ### Added 44 | - Stored sessions are now auto-renewed when expired, or nearing expiry 45 | 46 | ## [0.2.7] - 2023-12-06 47 | 48 | ### Changed 49 | - Various release packaging updates - thanks: [@clayrosenthal](https://github.com/clayrosenthal) -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to sn-cli 2 | 3 | Thank you for your interest in contributing to sn-cli! We welcome contributions from the community. 4 | 5 | ## How to Contribute 6 | 7 | ### Reporting Issues 8 | 9 | - Check if the issue already exists in [GitHub Issues](https://github.com/jonhadfield/sn-cli/issues) 10 | - Include your OS, Go version, and sn-cli version 11 | - Provide steps to reproduce the issue 12 | - Include relevant error messages or logs 13 | 14 | ### Submitting Pull Requests 15 | 16 | 1. **Fork and Clone** 17 | ```bash 18 | git clone https://github.com/your-username/sn-cli.git 19 | cd sn-cli 20 | ``` 21 | 22 | 2. **Create a Branch** 23 | ```bash 24 | git checkout -b feature/your-feature-name 25 | # or 26 | git checkout -b fix/your-bug-fix 27 | ``` 28 | 29 | 3. **Make Changes** 30 | - Follow existing code style and conventions 31 | - Add tests for new functionality 32 | - Update documentation if needed 33 | 34 | 4. **Test Your Changes** 35 | ```bash 36 | make test 37 | make lint 38 | ``` 39 | 40 | 5. **Commit and Push** 41 | ```bash 42 | git add . 43 | git commit -m "Brief description of changes" 44 | git push origin your-branch-name 45 | ``` 46 | 47 | 6. **Open a Pull Request** 48 | - Provide a clear description of the changes 49 | - Reference any related issues 50 | - Ensure all checks pass 51 | 52 | ## Development Setup 53 | 54 | ### Prerequisites 55 | - Go 1.25 or later 56 | - Make 57 | 58 | ### Building 59 | ```bash 60 | make build 61 | ``` 62 | 63 | ### Running Tests 64 | ```bash 65 | make test # Run all tests 66 | make cover # Generate coverage report 67 | ``` 68 | 69 | ### Code Quality 70 | ```bash 71 | make lint # Run linters 72 | make fmt # Format code 73 | ``` 74 | 75 | ## Code Guidelines 76 | 77 | - **Go Standards**: Follow [Effective Go](https://golang.org/doc/effective_go.html) guidelines 78 | - **Error Handling**: Always handle errors explicitly 79 | - **Testing**: Write tests for new features and bug fixes 80 | - **Comments**: Add comments for complex logic 81 | - **Commits**: Use clear, descriptive commit messages 82 | 83 | ## Testing 84 | 85 | - Unit tests should be included for new functionality 86 | - Integration tests for API interactions are welcome 87 | - Test with both Standard Notes cloud and self-hosted servers when possible 88 | 89 | ## Questions? 90 | 91 | Feel free to open an issue for discussion or clarification about contributing. 92 | 93 | ## License 94 | 95 | By contributing to sn-cli, you agree that your contributions will be licensed under the MIT License. 96 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SOURCE_FILES?=$$(go list ./... | grep -v /vendor/ | grep -v /mocks/) 2 | TEST_PATTERN?=. 3 | TEST_OPTIONS?=-race -v 4 | 5 | clean: 6 | rm -rf ./dist 7 | 8 | setup: 9 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.55.2 10 | go install -v github.com/go-critic/go-critic/cmd/gocritic@latest 11 | 12 | test: 13 | echo 'mode: atomic' > coverage.txt && go list ./... | xargs -n1 -I{} sh -c 'go test -v -failfast -p 1 -parallel 1 -timeout=600s -covermode=atomic -coverprofile=coverage.tmp {} && tail -n +2 coverage.tmp >> coverage.txt' && rm coverage.tmp 14 | 15 | cover: test 16 | go tool cover -html=coverage.txt 17 | 18 | fmt: 19 | find . -name '*.go' | while read -r file; do gofumpt -w "$$file"; gofumports -w "$$file"; done 20 | 21 | lint: 22 | golangci-lint run 23 | 24 | ci: lint test 25 | 26 | BUILD_TAG := $(shell git describe --tags 2>/dev/null) 27 | BUILD_SHA := $(shell git rev-parse --short HEAD) 28 | BUILD_DATE := $(shell date -u '+%Y/%m/%d:%H:%M:%S') 29 | 30 | build: 31 | CGO_ENABLED=0 go build -ldflags '-s -w -X "main.version=[$(BUILD_TAG)-$(BUILD_SHA)] $(BUILD_DATE) UTC"' -o ".local_dist/sncli" cmd/sncli/*.go 32 | 33 | build-all: 34 | GOOS=darwin CGO_ENABLED=0 GOARCH=amd64 go build -ldflags '-s -w -X "main.version=[$(BUILD_TAG)-$(BUILD_SHA)] $(BUILD_DATE) UTC"' -o ".local_dist/sncli_darwin_amd64" cmd/sncli/*.go 35 | GOOS=darwin CGO_ENABLED=0 GOARCH=arm64 go build -ldflags '-s -w -X "main.version=[$(BUILD_TAG)-$(BUILD_SHA)] $(BUILD_DATE) UTC"' -o ".local_dist/sncli_darwin_arm64" cmd/sncli/*.go 36 | GOOS=linux CGO_ENABLED=0 GOARCH=amd64 go build -ldflags '-s -w -X "main.version=[$(BUILD_TAG)-$(BUILD_SHA)] $(BUILD_DATE) UTC"' -o ".local_dist/sncli_linux_amd64" cmd/sncli/*.go 37 | GOOS=linux CGO_ENABLED=0 GOARCH=arm go build -ldflags '-s -w -X "main.version=[$(BUILD_TAG)-$(BUILD_SHA)] $(BUILD_DATE) UTC"' -o ".local_dist/sncli_linux_arm" cmd/sncli/*.go 38 | GOOS=linux CGO_ENABLED=0 GOARCH=arm64 go build -ldflags '-s -w -X "main.version=[$(BUILD_TAG)-$(BUILD_SHA)] $(BUILD_DATE) UTC"' -o ".local_dist/sncli_linux_arm64" cmd/sncli/*.go 39 | GOOS=netbsd CGO_ENABLED=0 GOARCH=amd64 go build -ldflags '-s -w -X "main.version=[$(BUILD_TAG)-$(BUILD_SHA)] $(BUILD_DATE) UTC"' -o ".local_dist/sncli_netbsd_amd64" cmd/sncli/*.go 40 | GOOS=openbsd CGO_ENABLED=0 GOARCH=amd64 go build -ldflags '-s -w -X "main.version=[$(BUILD_TAG)-$(BUILD_SHA)] $(BUILD_DATE) UTC"' -o ".local_dist/sncli_openbsd_amd64" cmd/sncli/*.go 41 | GOOS=freebsd CGO_ENABLED=0 GOARCH=amd64 go build -ldflags '-s -w -X "main.version=[$(BUILD_TAG)-$(BUILD_SHA)] $(BUILD_DATE) UTC"' -o ".local_dist/sncli_freebsd_amd64" cmd/sncli/*.go 42 | GOOS=windows CGO_ENABLED=0 GOARCH=amd64 go build -ldflags '-s -w -X "main.version=[$(BUILD_TAG)-$(BUILD_SHA)] $(BUILD_DATE) UTC"' -o ".local_dist/sncli_windows_amd64.exe" cmd/sncli/*.go 43 | 44 | install: 45 | go install ./cmd/... 46 | 47 | build-linux: 48 | GOOS=linux CGO_ENABLED=0 GOARCH=amd64 go build -ldflags '-s -w -X "main.version=[$(BUILD_TAG)-$(BUILD_SHA)] $(BUILD_DATE) UTC"' -o ".local_dist/sncli_linux_amd64" cmd/sncli/*.go 49 | 50 | mac-install: build 51 | install .local_dist/sncli /usr/local/bin/sn 52 | 53 | linux-install: build-linux 54 | sudo install .local_dist/sncli /usr/local/bin/sn 55 | 56 | find-updates: 57 | go list -u -m -json all | go-mod-outdated -update -direct 58 | 59 | critic: 60 | gocritic check ./... 61 | 62 | help: 63 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 64 | 65 | .DEFAULT_GOAL := build 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📝 sn-cli 2 | 3 | > A modern command-line interface for [Standard Notes](https://standardnotes.org/) 4 | 5 | [![Build Status](https://www.travis-ci.org/jonhadfield/sn-cli.svg?branch=master)](https://www.travis-ci.org/jonhadfield/sn-cli) [![Go Report Card](https://goreportcard.com/badge/github.com/jonhadfield/sn-cli)](https://goreportcard.com/report/github.com/jonhadfield/sn-cli) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 6 | 7 | ## ✨ Features 8 | 9 | - **📋 Notes & Tasks**: Create, edit, and manage notes and checklists 10 | - **🏷️ Tags**: Organize content with flexible tagging 11 | - **📊 Statistics**: Detailed analytics about your notes and usage 12 | - **🔐 Secure Sessions**: Keychain integration for macOS and Linux 13 | - **⚡ Fast Sync**: Efficient synchronization with Standard Notes servers 14 | - **🔄 Multi-Platform**: Windows, macOS, and Linux support 15 | 16 | ## 🚀 Quick Start 17 | 18 | ### Installation 19 | 20 | **Download the latest release:** 21 | ```bash 22 | # macOS/Linux 23 | curl -L https://github.com/jonhadfield/sn-cli/releases/latest/download/sncli_$(uname -s)_$(uname -m) -o sn 24 | chmod +x sn && sudo mv sn /usr/local/bin/ 25 | 26 | # Or via direct download 27 | # Visit: https://github.com/jonhadfield/sn-cli/releases 28 | ``` 29 | 30 | ### First Run 31 | 32 | ```bash 33 | # See all available commands 34 | sn --help 35 | 36 | # Add a note 37 | sn add note --title "My First Note" --text "Hello, Standard Notes!" 38 | 39 | # List your notes 40 | sn get notes 41 | 42 | # View statistics 43 | sn stats 44 | ``` 45 | 46 | ## 📋 Commands 47 | 48 | | Command | Description | 49 | |---------|-------------| 50 | | `add` | Add notes, tags, or tasks | 51 | | `delete` | Delete items by title or UUID | 52 | | `edit` | Edit existing notes | 53 | | `get` | Retrieve notes, tags, or tasks | 54 | | `tag` | Manage tags and tagging | 55 | | `task` | Manage checklists and advanced checklists | 56 | | `stats` | Display detailed statistics | 57 | | `session` | Manage stored sessions | 58 | | `register` | Register a new Standard Notes account | 59 | | `resync` | Refresh local cache | 60 | | `wipe` | Delete all notes and tags | 61 | 62 | *Note: Export and import are temporarily disabled due to recent Standard Notes API changes* 63 | 64 | ## 🔐 Authentication 65 | 66 | ### Environment Variables 67 | ```bash 68 | export SN_EMAIL="your-email@example.com" 69 | export SN_PASSWORD="your-password" 70 | export SN_SERVER="https://api.standardnotes.com" # Optional for self-hosted 71 | ``` 72 | 73 | ### Session Storage (Recommended) 74 | Store encrypted sessions in your system keychain: 75 | 76 | ```bash 77 | # Add session (supports 2FA) 78 | sn session --add 79 | 80 | # Add encrypted session 81 | sn session --add --session-key 82 | 83 | # Use session automatically 84 | export SN_USE_SESSION=true 85 | # or 86 | sn --use-session get notes 87 | ``` 88 | 89 | ## 🆕 Recent Updates 90 | 91 | ### Version 0.3.5 (2024-01-08) 92 | - 🐛 **Fixed**: Conflict warning handling 93 | - ✅ **Added**: Helper tests 94 | - 🔧 **Improved**: Code simplification 95 | 96 | ### Version 0.3.4 (2024-01-07) 97 | - 🐛 **Fixed**: Command completion and updated instructions 98 | 99 | **[View full changelog →](CHANGELOG.md)** 100 | 101 | ## 💡 Examples 102 | 103 | ```bash 104 | # Create a note with tags 105 | sn add note --title "Meeting Notes" --text "Important discussion points" --tag work,meetings 106 | 107 | # Find notes by tag 108 | sn get notes --tag work 109 | 110 | # Create a checklist 111 | sn add task --title "Todo List" --text "- Buy groceries\n- Call dentist\n- Finish project" 112 | 113 | # View your note statistics 114 | sn stats 115 | 116 | # Edit a note 117 | sn edit note --title "Meeting Notes" --text "Updated content" 118 | ``` 119 | 120 | ## ⚙️ Advanced Configuration 121 | 122 | ### Bash Completion 123 | 124 | #### macOS (Homebrew) 125 | ```bash 126 | brew install bash-completion 127 | echo '[ -f /usr/local/etc/bash_completion ] && . /usr/local/etc/bash_completion' >> ~/.bash_profile 128 | ``` 129 | 130 | #### Install completion script 131 | ```bash 132 | # macOS 133 | cp bash_autocomplete /usr/local/etc/bash_completion.d/sn 134 | echo "source /usr/local/etc/bash_completion.d/sn" >> ~/.bashrc 135 | 136 | # Linux 137 | cp bash_autocomplete /etc/bash_completion.d/sn 138 | echo "source /etc/bash_completion.d/sn" >> ~/.bashrc 139 | ``` 140 | 141 | ### Self-Hosted Servers 142 | ```bash 143 | export SN_SERVER="https://your-standardnotes-server.com" 144 | ``` 145 | 146 | ## 🔧 Development 147 | 148 | ```bash 149 | # Build from source 150 | git clone https://github.com/jonhadfield/sn-cli.git 151 | cd sn-cli 152 | make build 153 | 154 | # Run tests 155 | make test 156 | 157 | # View all make targets 158 | make help 159 | ``` 160 | 161 | ## ⚠️ Known Issues 162 | 163 | - New accounts registered via sn-cli require initial login through the official web/desktop app to initialize encryption keys 164 | 165 | ## 🤝 Contributing 166 | 167 | Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests. 168 | 169 | ## 📄 License 170 | 171 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 172 | 173 | ## 🔗 Links 174 | 175 | - [Standard Notes](https://standardnotes.org/) - The note-taking app this CLI supports 176 | - [Releases](https://github.com/jonhadfield/sn-cli/releases) - Download the latest version 177 | - [Issues](https://github.com/jonhadfield/sn-cli/issues) - Report bugs or request features 178 | 179 | --- 180 | 181 | -------------------------------------------------------------------------------- /autocomplete/bash_autocomplete: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | : ${PROG:=$(basename ${BASH_SOURCE})} 4 | 5 | # Macs have bash3 for which the bash-completion package doesn't include 6 | # _init_completion. This is a minimal version of that function. 7 | _cli_init_completion() { 8 | COMPREPLY=() 9 | _get_comp_words_by_ref "$@" cur prev words cword 10 | } 11 | 12 | _cli_bash_autocomplete() { 13 | if [[ "${COMP_WORDS[0]}" != "source" ]]; then 14 | local cur opts base words 15 | COMPREPLY=() 16 | cur="${COMP_WORDS[COMP_CWORD]}" 17 | if declare -F _init_completion >/dev/null 2>&1; then 18 | _init_completion -n "=:" || return 19 | else 20 | _cli_init_completion -n "=:" || return 21 | fi 22 | words=("${words[@]:0:$cword}") 23 | if [[ "$cur" == "-"* ]]; then 24 | requestComp="${words[*]} ${cur} --generate-bash-completion" 25 | else 26 | requestComp="${words[*]} --generate-bash-completion" 27 | fi 28 | opts=$(eval "${requestComp}" 2>/dev/null) 29 | COMPREPLY=($(compgen -W "${opts}" -- ${cur})) 30 | return 0 31 | fi 32 | } 33 | 34 | complete -o bashdefault -o default -o nospace -F _cli_bash_autocomplete $PROG 35 | unset PROG 36 | -------------------------------------------------------------------------------- /autocomplete/powershell_autocomplete.ps1: -------------------------------------------------------------------------------- 1 | $fn = $($MyInvocation.MyCommand.Name) 2 | $name = $fn -replace "(.*)\.ps1$", '$1' 3 | Register-ArgumentCompleter -Native -CommandName $name -ScriptBlock { 4 | param($commandName, $wordToComplete, $cursorPosition) 5 | $other = "$wordToComplete --generate-bash-completion" 6 | Invoke-Expression $other | ForEach-Object { 7 | [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /autocomplete/zsh_autocomplete: -------------------------------------------------------------------------------- 1 | #compdef $PROG 2 | 3 | _cli_zsh_autocomplete() { 4 | local -a opts 5 | local cur 6 | cur=${words[-1]} 7 | if [[ "$cur" == "-"* ]]; then 8 | opts=("${(@f)$(${words[@]:0:#words[@]-1} ${cur} --generate-bash-completion)}") 9 | else 10 | opts=("${(@f)$(${words[@]:0:#words[@]-1} --generate-bash-completion)}") 11 | fi 12 | 13 | if [[ "${opts[1]}" != "" ]]; then 14 | _describe 'values' opts 15 | else 16 | _files 17 | fi 18 | } 19 | 20 | compdef _cli_zsh_autocomplete $PROG 21 | -------------------------------------------------------------------------------- /bin/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonhadfield/sn-cli/3099bdfdfdb10b2234db1f16e8104be0c5ce16a9/bin/.gitkeep -------------------------------------------------------------------------------- /cache.go: -------------------------------------------------------------------------------- 1 | package sncli 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/jonhadfield/gosn-v2/cache" 9 | ) 10 | 11 | func Resync(s *cache.Session, cacheDBDir, appName string) error { 12 | var err error 13 | 14 | // check if cache db dir 15 | if cacheDBDir != "" { 16 | _, err = os.Stat(s.CacheDBPath) 17 | if err != nil { 18 | if os.IsNotExist(err) { 19 | return errors.New("specified cache directory does not exist") 20 | } 21 | 22 | return err 23 | } 24 | } 25 | 26 | s.CacheDBPath, err = cache.GenCacheDBPath(*s, cacheDBDir, appName) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | fmt.Printf("deleting cache db at %s\n", s.CacheDBPath) 32 | if s.CacheDBPath != "" { 33 | err = os.Remove(s.CacheDBPath) 34 | if err != nil { 35 | return err 36 | } 37 | } 38 | 39 | _, err = Sync(cache.SyncInput{ 40 | Session: s, 41 | }, true) 42 | 43 | return err 44 | } 45 | -------------------------------------------------------------------------------- /cmd/sncli/add.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | func cmdAdd() *cli.Command { 10 | return &cli.Command{ 11 | Name: "add", 12 | Usage: "add items", 13 | BashComplete: func(c *cli.Context) { 14 | if c.NArg() > 0 { 15 | return 16 | } 17 | for _, t := range []string{"tag", "note"} { 18 | fmt.Println(t) 19 | } 20 | }, 21 | Subcommands: []*cli.Command{ 22 | { 23 | Name: "tag", 24 | Usage: "add tags", 25 | BashComplete: func(c *cli.Context) { 26 | if c.NArg() > 0 { 27 | return 28 | } 29 | for _, t := range []string{"--title", "--parent", "--parent-uuid"} { 30 | fmt.Println(t) 31 | } 32 | }, 33 | Flags: []cli.Flag{ 34 | &cli.StringFlag{ 35 | Name: "title", 36 | Usage: "new tag title (separate multiple with commas)", 37 | }, 38 | &cli.StringFlag{ 39 | Name: "parent", 40 | Usage: "parent tag title to make a sub-tag of", 41 | }, 42 | &cli.StringFlag{ 43 | Name: "parent-uuid", 44 | Usage: "parent tag uuid to make a sub-tag of", 45 | }, 46 | }, 47 | Action: func(c *cli.Context) error { 48 | opts := getOpts(c) 49 | // useStdOut = opts.useStdOut 50 | return processAddTags(c, opts) 51 | }, 52 | }, 53 | { 54 | Name: "note", 55 | Usage: "add a note", 56 | BashComplete: func(c *cli.Context) { 57 | addNoteOpts := []string{"--title", "--text", "--tag", "--replace"} 58 | if c.NArg() > 0 { 59 | return 60 | } 61 | for _, ano := range addNoteOpts { 62 | fmt.Println(ano) 63 | } 64 | }, 65 | Flags: []cli.Flag{ 66 | &cli.StringFlag{ 67 | Name: "title", 68 | Usage: "new note title", 69 | }, 70 | &cli.StringFlag{ 71 | Name: "text", 72 | Usage: "new note text", 73 | }, 74 | &cli.StringFlag{ 75 | Name: "file", 76 | Usage: "path to file with note content (specify --title or leave blank to use filename)", 77 | }, 78 | &cli.StringFlag{ 79 | Name: "tag", 80 | Usage: "associate with tag (separate multiple with commas)", 81 | }, 82 | &cli.BoolFlag{ 83 | Name: "replace", 84 | Usage: "replace note with same title", 85 | }, 86 | }, 87 | Action: func(c *cli.Context) error { 88 | opts := getOpts(c) 89 | 90 | return processAddNotes(c, opts) 91 | }, 92 | }, 93 | }, 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /cmd/sncli/add_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | -------------------------------------------------------------------------------- /cmd/sncli/debug.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jonhadfield/gosn-v2/session" 7 | sncli "github.com/jonhadfield/sn-cli" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func cmdDebug() *cli.Command { 12 | return &cli.Command{ 13 | Name: "debug", 14 | Usage: "debug tools", 15 | Hidden: true, 16 | BashComplete: func(c *cli.Context) { 17 | addTasks := []string{"decrypt-string"} 18 | if c.NArg() > 0 { 19 | return 20 | } 21 | for _, t := range addTasks { 22 | fmt.Println(t) 23 | } 24 | }, 25 | Subcommands: []*cli.Command{ 26 | { 27 | Name: "decrypt-string", 28 | Usage: "accepts a string in the format: ::, decrypts it using the session key (or one specified with --key) and returns the decrypted ciphertext", 29 | BashComplete: func(c *cli.Context) { 30 | hcKeysOpts := []string{"--key"} 31 | if c.NArg() > 0 { 32 | return 33 | } 34 | for _, ano := range hcKeysOpts { 35 | fmt.Println(ano) 36 | } 37 | }, 38 | Flags: []cli.Flag{ 39 | &cli.StringFlag{ 40 | Name: "key", 41 | Usage: "override session's master key", 42 | }, 43 | }, 44 | Action: func(c *cli.Context) error { 45 | str := "" 46 | if c.Args().Present() { 47 | fmt.Printf("c.Args() %+v\n", c.Args()) 48 | fmt.Printf("c.Args() %+v\n", c.Args().First()) 49 | str = c.Args().First() 50 | } 51 | 52 | opts := getOpts(c) 53 | 54 | sess, _, err := session.GetSession(nil, opts.useSession, opts.sessKey, opts.server, opts.debug) 55 | if err != nil { 56 | return err 57 | } 58 | // var res string 59 | _, err = sncli.DecryptString(sncli.DecryptStringInput{ 60 | Session: sess, 61 | UseStdOut: opts.useStdOut, 62 | Key: c.String("key"), 63 | In: str, 64 | }) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | // msg = fmt.Sprintf("plaintext: %s", res) 70 | 71 | return err 72 | }, 73 | }, 74 | }, 75 | } 76 | } 77 | 78 | // { 79 | // Name: "create-itemskey", 80 | // Usage: "creates and displays an items key without syncing", 81 | // BashComplete: func(c *cli.Context) { 82 | // hcKeysOpts := []string{"--master-key"} 83 | // if c.NArg() > 0 { 84 | // return 85 | // } 86 | // for _, ano := range hcKeysOpts { 87 | // fmt.Println(ano) 88 | // } 89 | // }, 90 | // Flags: []cli.Flag{ 91 | // cli.StringFlag{ 92 | // Name: "master-key", 93 | // Usage: "master key to encrypt the encrypted item key with", 94 | // }, 95 | // }, 96 | // Action: func(c *cli.Context) error { 97 | // var opts configOptsOutput 98 | // opts, err = getOpts(c) 99 | // if err != nil { 100 | // return err 101 | // } 102 | // // useStdOut = opts.useStdOut 103 | // 104 | // return sncli.CreateItemsKey(sncli.CreateItemsKeyInput{ 105 | // Debug: opts.debug, 106 | // MasterKey: c.String("master-key"), 107 | // }) 108 | // }, 109 | // }, 110 | -------------------------------------------------------------------------------- /cmd/sncli/delete.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | func cmdDelete() *cli.Command { 10 | return &cli.Command{ 11 | Name: "delete", 12 | Usage: "delete items", 13 | BashComplete: func(c *cli.Context) { 14 | addTasks := []string{"tag", "note"} 15 | if c.NArg() > 0 { 16 | return 17 | } 18 | for _, t := range addTasks { 19 | fmt.Println(t) 20 | } 21 | }, 22 | Subcommands: []*cli.Command{ 23 | { 24 | Name: "tag", 25 | Usage: "delete tag", 26 | BashComplete: func(c *cli.Context) { 27 | delTagOpts := []string{"--title", "--uuid"} 28 | if c.NArg() > 0 { 29 | return 30 | } 31 | for _, t := range delTagOpts { 32 | fmt.Println(t) 33 | } 34 | }, 35 | Flags: []cli.Flag{ 36 | &cli.StringFlag{ 37 | Name: "title", 38 | Usage: "title of note to delete (separate multiple with commas)", 39 | }, 40 | &cli.StringFlag{ 41 | Name: "uuid", 42 | Usage: "unique id of note to delete (separate multiple with commas)", 43 | }, 44 | }, 45 | Action: func(c *cli.Context) error { 46 | opts := getOpts(c) 47 | 48 | return processDeleteTags(c, opts) 49 | }, 50 | }, 51 | { 52 | Name: "note", 53 | Usage: "delete note", 54 | BashComplete: func(c *cli.Context) { 55 | delNoteOpts := []string{"--title", "--uuid"} 56 | if c.NArg() > 0 { 57 | return 58 | } 59 | for _, t := range delNoteOpts { 60 | fmt.Println(t) 61 | } 62 | }, 63 | Flags: []cli.Flag{ 64 | &cli.StringFlag{ 65 | Name: "title", 66 | Usage: "title of note to delete (separate multiple with commas)", 67 | }, 68 | &cli.StringFlag{ 69 | Name: "uuid", 70 | Usage: "unique id of note to delete (separate multiple with commas)", 71 | }, 72 | }, 73 | Action: func(c *cli.Context) error { 74 | opts := getOpts(c) 75 | 76 | return processDeleteNote(c, opts) 77 | }, 78 | }, 79 | { 80 | Name: "item", 81 | Usage: "delete any standard notes item", 82 | BashComplete: func(c *cli.Context) { 83 | delNoteOpts := []string{"--uuid"} 84 | if c.NArg() > 0 { 85 | return 86 | } 87 | for _, t := range delNoteOpts { 88 | fmt.Println(t) 89 | } 90 | }, 91 | Flags: []cli.Flag{ 92 | &cli.StringFlag{ 93 | Name: "uuid", 94 | Usage: "unique id of item to delete (separate multiple with commas)", 95 | }, 96 | }, 97 | Action: func(c *cli.Context) error { 98 | opts := getOpts(c) 99 | 100 | return processDeleteItems(c, opts) 101 | }, 102 | }, 103 | }, 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /cmd/sncli/edit.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | func cmdEdit() *cli.Command { 10 | return &cli.Command{ 11 | Name: "edit", 12 | Usage: "edit items", 13 | BashComplete: func(c *cli.Context) { 14 | addTasks := []string{"tag", "note"} 15 | if c.NArg() > 0 { 16 | return 17 | } 18 | for _, t := range addTasks { 19 | fmt.Println(t) 20 | } 21 | }, 22 | Subcommands: []*cli.Command{ 23 | { 24 | Name: "tag", 25 | Usage: "edit a tag", 26 | BashComplete: func(c *cli.Context) { 27 | addNoteOpts := []string{"--title", "--uuid"} 28 | if c.NArg() > 0 { 29 | return 30 | } 31 | for _, ano := range addNoteOpts { 32 | fmt.Println(ano) 33 | } 34 | }, 35 | Flags: []cli.Flag{ 36 | &cli.StringFlag{ 37 | Name: "title", 38 | Usage: "title of the tag", 39 | }, 40 | &cli.StringFlag{ 41 | Name: "uuid", 42 | Usage: "uuid of the tag", 43 | }, 44 | }, 45 | Action: func(c *cli.Context) error { 46 | opts := getOpts(c) 47 | 48 | return processEditTag(c, opts) 49 | }, 50 | }, 51 | { 52 | Name: "note", 53 | Usage: "edit a note", 54 | BashComplete: func(c *cli.Context) { 55 | addNoteOpts := []string{"--title", "--uuid", "--editor"} 56 | if c.NArg() > 0 { 57 | return 58 | } 59 | for _, ano := range addNoteOpts { 60 | fmt.Println(ano) 61 | } 62 | }, 63 | Flags: []cli.Flag{ 64 | &cli.StringFlag{ 65 | Name: "title", 66 | Usage: "title of the note", 67 | }, 68 | &cli.StringFlag{ 69 | Name: "uuid", 70 | Usage: "uuid of the note", 71 | }, 72 | &cli.StringFlag{ 73 | Name: "editor", 74 | Usage: "path to editor", 75 | EnvVars: []string{"EDITOR"}, 76 | }, 77 | }, 78 | Action: func(c *cli.Context) error { 79 | opts := getOpts(c) 80 | // useStdOut = opts.useStdOut 81 | return processEditNote(c, opts) 82 | }, 83 | }, 84 | }, 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /cmd/sncli/export.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // { 4 | // Name: "export", 5 | // Usage: "export data", 6 | // Hidden: true, 7 | // Flags: []cli.Flag{ 8 | // cli.StringFlag{ 9 | // Name: "path", 10 | // Usage: "choose directory to place export in (default: current directory)", 11 | // }, 12 | // }, 13 | // Action: func(c *cli.Context) error { 14 | // var opts configOptsOutput 15 | // opts, err = getOpts(c) 16 | // if err != nil { 17 | // return err 18 | // } 19 | // // useStdOut = opts.useStdOut 20 | // 21 | // outputPath := strings.TrimSpace(c.String("output")) 22 | // if outputPath == "" { 23 | // outputPath, err = os.Getwd() 24 | // if err != nil { 25 | // return err 26 | // } 27 | // } 28 | // 29 | // timeStamp := time.Now().UTC().Format("20060102150405") 30 | // filePath := fmt.Sprintf("standard_notes_export_%s.json", timeStamp) 31 | // outputPath += string(os.PathSeparator) + filePath 32 | // 33 | // var sess cache.Session 34 | // sess, _, err = cache.GetSession(opts.useSession, opts.sessKey, opts.server, opts.debug) 35 | // if err != nil { 36 | // return err 37 | // } 38 | // 39 | // var cacheDBPath string 40 | // cacheDBPath, err = cache.GenCacheDBPath(sess, opts.cacheDBDir, snAppName) 41 | // if err != nil { 42 | // return err 43 | // } 44 | // 45 | // sess.Debug = opts.debug 46 | // 47 | // sess.CacheDBPath = cacheDBPath 48 | // appExportConfig := sncli.ExportConfig{ 49 | // Session: &sess, 50 | // Decrypted: c.Bool("decrypted"), 51 | // File: outputPath, 52 | // } 53 | // err = appExportConfig.Run() 54 | // if err == nil { 55 | // msg = fmt.Sprintf("encrypted export written to: %s", outputPath) 56 | // } 57 | // 58 | // return err 59 | // }, 60 | // }, 61 | // { 62 | // Name: "import", 63 | // Usage: "import data", 64 | // Hidden: true, 65 | // Flags: []cli.Flag{ 66 | // cli.StringFlag{ 67 | // Name: "file", 68 | // Usage: "path of file to import", 69 | // }, 70 | // cli.BoolFlag{ 71 | // Name: "experiment", 72 | // Usage: "test import functionality - only use after taking backup as this is experimental", 73 | // }, 74 | // }, 75 | // Action: func(c *cli.Context) error { 76 | // var opts configOptsOutput 77 | // opts, err = getOpts(c) 78 | // if err != nil { 79 | // return err 80 | // } 81 | // 82 | // // useStdOut = opts.useStdOut 83 | // 84 | // inputPath := strings.TrimSpace(c.String("file")) 85 | // if inputPath == "" { 86 | // return errors.New("please specify path using --file") 87 | // } 88 | // 89 | // if !c.Bool("experiment") { 90 | // fmt.Printf("\nWARNING: The import functionality is currently for testing only\nDo not use unless you have a backup of your data and intend to restore after testing\nTo proceed run the command with flag --experiment\n") 91 | // return nil 92 | // } 93 | // 94 | // var session cache.Session 95 | // session, _, err = cache.GetSession(opts.useSession, opts.sessKey, opts.server, opts.debug) 96 | // if err != nil { 97 | // return err 98 | // } 99 | // 100 | // session.CacheDBPath, err = cache.GenCacheDBPath(session, opts.cacheDBDir, snAppName) 101 | // if err != nil { 102 | // return err 103 | // } 104 | // 105 | // appImportConfig := sncli.ImportConfig{ 106 | // Session: &session, 107 | // File: inputPath, 108 | // Format: c.String("format"), 109 | // Debug: opts.debug, 110 | // UseStdOut: opts.useStdOut, 111 | // } 112 | // 113 | // var imported int 114 | // imported, err = appImportConfig.Run() 115 | // if err == nil { 116 | // msg = fmt.Sprintf("imported %d items", imported) 117 | // } else { 118 | // msg = "import failed" 119 | // } 120 | // 121 | // return err 122 | // }, 123 | // }, 124 | -------------------------------------------------------------------------------- /cmd/sncli/get.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/jonhadfield/gosn-v2/cache" 9 | "github.com/jonhadfield/gosn-v2/common" 10 | "github.com/jonhadfield/gosn-v2/items" 11 | sncli "github.com/jonhadfield/sn-cli" 12 | "github.com/urfave/cli/v2" 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | func cmdGet() *cli.Command { 17 | return &cli.Command{ 18 | Name: "get", 19 | Usage: "get items", 20 | BashComplete: func(c *cli.Context) { 21 | addTasks := []string{"tag", "note"} 22 | if c.NArg() > 0 { 23 | return 24 | } 25 | for _, t := range addTasks { 26 | fmt.Println(t) 27 | } 28 | }, 29 | Subcommands: []*cli.Command{ 30 | { 31 | Name: "settings", 32 | Aliases: []string{"setting"}, 33 | Usage: "get settings", 34 | Hidden: true, 35 | Flags: []cli.Flag{ 36 | &cli.BoolFlag{ 37 | Name: "count", 38 | Usage: "useStdOut count only", 39 | }, 40 | &cli.StringFlag{ 41 | Name: "output", 42 | Value: "json", 43 | Usage: "output format", 44 | }, 45 | }, 46 | OnUsageError: func(c *cli.Context, err error, isSubcommand bool) error { 47 | return err 48 | }, 49 | Action: func(c *cli.Context) error { 50 | opts := getOpts(c) 51 | 52 | // useStdOut = opts.useStdOut 53 | 54 | var matchAny bool 55 | if c.Bool("match-all") { 56 | matchAny = false 57 | } 58 | 59 | count := c.Bool("count") 60 | 61 | getSettingssIF := items.ItemFilters{ 62 | MatchAny: matchAny, 63 | Filters: []items.Filter{ 64 | {Type: "Setting"}, 65 | }, 66 | } 67 | 68 | sess, _, err := cache.GetSession(common.NewHTTPClient(), opts.useSession, opts.sessKey, opts.server, opts.debug) 69 | if err != nil { 70 | return err 71 | } 72 | ss := sess.Gosn() 73 | // sync to get keys 74 | gsi := items.SyncInput{ 75 | Session: &ss, 76 | } 77 | _, err = items.Sync(gsi) 78 | if err != nil { 79 | return err 80 | } 81 | var cacheDBPath string 82 | cacheDBPath, err = cache.GenCacheDBPath(sess, opts.cacheDBDir, snAppName) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | sess.CacheDBPath = cacheDBPath 88 | 89 | // TODO: validate output 90 | output := c.String("output") 91 | appGetSettingsConfig := sncli.GetSettingsConfig{ 92 | Session: &sess, 93 | Filters: getSettingssIF, 94 | Output: output, 95 | Debug: opts.debug, 96 | } 97 | var rawSettings items.Items 98 | rawSettings, err = appGetSettingsConfig.Run() 99 | if err != nil { 100 | return err 101 | } 102 | var settingsYAML []sncli.SettingYAML 103 | var settingsJSON []sncli.SettingJSON 104 | var numResults int 105 | for _, rt := range rawSettings { 106 | numResults++ 107 | if !count && sncli.StringInSlice(output, yamlAbbrevs, false) { 108 | tagContentOrgStandardNotesSNDetailYAML := sncli.OrgStandardNotesSNDetailYAML{ 109 | ClientUpdatedAt: rt.(*items.Component).Content.GetAppData().OrgStandardNotesSN.ClientUpdatedAt, 110 | } 111 | tagContentAppDataContent := sncli.AppDataContentYAML{ 112 | OrgStandardNotesSN: tagContentOrgStandardNotesSNDetailYAML, 113 | } 114 | 115 | settingContentYAML := sncli.SettingContentYAML{ 116 | Title: rt.(*items.Component).Content.GetTitle(), 117 | ItemReferences: sncli.ItemRefsToYaml(rt.(*items.Component).Content.References()), 118 | AppData: tagContentAppDataContent, 119 | } 120 | 121 | settingsYAML = append(settingsYAML, sncli.SettingYAML{ 122 | UUID: rt.(*items.Component).UUID, 123 | ContentType: rt.(*items.Component).ContentType, 124 | Content: settingContentYAML, 125 | UpdatedAt: rt.(*items.Component).UpdatedAt, 126 | CreatedAt: rt.(*items.Component).CreatedAt, 127 | }) 128 | } 129 | if !count && strings.ToLower(output) == "json" { 130 | settingContentOrgStandardNotesSNDetailJSON := sncli.OrgStandardNotesSNDetailJSON{ 131 | ClientUpdatedAt: rt.(*items.Component).Content.GetAppData().OrgStandardNotesSN.ClientUpdatedAt, 132 | } 133 | settingContentAppDataContent := sncli.AppDataContentJSON{ 134 | OrgStandardNotesSN: settingContentOrgStandardNotesSNDetailJSON, 135 | } 136 | 137 | settingContentJSON := sncli.SettingContentJSON{ 138 | Title: rt.(*items.Component).Content.GetTitle(), 139 | ItemReferences: sncli.ItemRefsToJSON(rt.(*items.Component).Content.References()), 140 | AppData: settingContentAppDataContent, 141 | } 142 | 143 | settingsJSON = append(settingsJSON, sncli.SettingJSON{ 144 | UUID: rt.(*items.Component).UUID, 145 | ContentType: rt.(*items.Component).ContentType, 146 | Content: settingContentJSON, 147 | UpdatedAt: rt.(*items.Component).UpdatedAt, 148 | CreatedAt: rt.(*items.Component).CreatedAt, 149 | }) 150 | } 151 | } 152 | // if numResults <= 0 { 153 | // if count { 154 | // msg = "0" 155 | // } else { 156 | // msg = msgNoMatches 157 | // } 158 | // } else if count { 159 | // msg = strconv.Itoa(numResults) 160 | // } else { 161 | output = c.String("output") 162 | var bOutput []byte 163 | switch strings.ToLower(output) { 164 | case "json": 165 | bOutput, err = json.MarshalIndent(settingsJSON, "", " ") 166 | case "yaml": 167 | bOutput, err = yaml.Marshal(settingsYAML) 168 | } 169 | if len(bOutput) > 0 { 170 | fmt.Println(string(bOutput)) 171 | } 172 | 173 | return err 174 | }, 175 | }, 176 | { 177 | Name: "tag", 178 | Aliases: []string{"tags"}, 179 | Usage: "get tags", 180 | BashComplete: func(c *cli.Context) { 181 | tagTasks := []string{ 182 | "--title", "--uuid", "--regex", "--match-all", "--count", "--output", 183 | } 184 | if c.NArg() > 0 { 185 | return 186 | } 187 | for _, t := range tagTasks { 188 | fmt.Println(t) 189 | } 190 | }, 191 | Flags: []cli.Flag{ 192 | &cli.StringFlag{ 193 | Name: "title", 194 | Usage: "find by title (separate multiple by commas)", 195 | }, 196 | &cli.StringFlag{ 197 | Name: "uuid", 198 | Usage: "find by uuid (separate multiple by commas)", 199 | }, 200 | &cli.BoolFlag{ 201 | Name: "regex", 202 | Usage: "enable regular expressions", 203 | }, 204 | &cli.BoolFlag{ 205 | Name: "match-all", 206 | Usage: "match all search criteria (default: match any)", 207 | }, 208 | &cli.BoolFlag{ 209 | Name: "count", 210 | Usage: "useStdOut count only", 211 | }, 212 | &cli.StringFlag{ 213 | Name: "output", 214 | Value: "json", 215 | Usage: "output format", 216 | }, 217 | }, 218 | OnUsageError: func(c *cli.Context, err error, isSubcommand bool) error { 219 | return err 220 | }, 221 | Action: func(c *cli.Context) error { 222 | opts := getOpts(c) 223 | 224 | return processGetTags(c, opts) 225 | }, 226 | }, 227 | { 228 | Name: "note", 229 | Aliases: []string{"notes"}, 230 | Usage: "get notes", 231 | BashComplete: func(c *cli.Context) { 232 | addTasks := []string{"--title", "--text", "--tag", "--uuid", "--editor", "--include-trash", "--count"} 233 | if c.NArg() > 0 { 234 | return 235 | } 236 | for _, t := range addTasks { 237 | fmt.Println(t) 238 | } 239 | }, 240 | Flags: []cli.Flag{ 241 | &cli.StringFlag{ 242 | Name: "title", 243 | Usage: "find by title", 244 | }, 245 | &cli.StringFlag{ 246 | Name: "text", 247 | Usage: "find by text", 248 | }, 249 | &cli.StringFlag{ 250 | Name: "tag", 251 | Usage: "find by tag", 252 | }, 253 | &cli.StringFlag{ 254 | Name: "uuid", 255 | Usage: "find by uuid", 256 | }, 257 | &cli.StringFlag{ 258 | Name: "editor", 259 | Usage: "find by associated editor", 260 | }, 261 | &cli.BoolFlag{ 262 | Name: "include-trash", 263 | Usage: "include notes in trash", 264 | }, 265 | &cli.BoolFlag{ 266 | Name: "count", 267 | Usage: "number of notes", 268 | }, 269 | &cli.StringFlag{ 270 | Name: "output", 271 | Value: "json", 272 | Usage: "output format", 273 | }, 274 | }, 275 | Action: func(c *cli.Context) error { 276 | opts := getOpts(c) 277 | // useStdOut = opts.useStdOut 278 | return processGetNotes(c, opts) 279 | }, 280 | }, 281 | { 282 | Name: "item", 283 | Aliases: []string{"items"}, 284 | Usage: "get any standard notes item", 285 | BashComplete: func(c *cli.Context) { 286 | getItemOpts := []string{"--uuid"} 287 | if c.NArg() > 0 { 288 | return 289 | } 290 | for _, t := range getItemOpts { 291 | fmt.Println(t) 292 | } 293 | }, 294 | Flags: []cli.Flag{ 295 | &cli.StringFlag{ 296 | Name: "uuid", 297 | Usage: "unique id of item to return (separate multiple with commas)", 298 | }, 299 | &cli.StringFlag{ 300 | Name: "output", 301 | Value: "json", 302 | Usage: "output format", 303 | }, 304 | }, 305 | Action: func(c *cli.Context) error { 306 | opts := getOpts(c) 307 | 308 | // useStdOut = opts.useStdOut 309 | 310 | return processGetItems(c, opts) 311 | }, 312 | }, 313 | { 314 | Name: "trash", 315 | Aliases: []string{"trashed"}, 316 | Usage: "get notes in trash", 317 | Hidden: true, 318 | Flags: []cli.Flag{ 319 | &cli.StringFlag{ 320 | Name: "title", 321 | Usage: "find by title", 322 | }, 323 | &cli.StringFlag{ 324 | Name: "text", 325 | Usage: "find by text", 326 | }, 327 | &cli.StringFlag{ 328 | Name: "tag", 329 | Usage: "find by tag", 330 | }, 331 | &cli.StringFlag{ 332 | Name: "uuid", 333 | Usage: "find by uuid", 334 | }, 335 | &cli.BoolFlag{ 336 | Name: "count", 337 | Usage: "useStdOut countonly", 338 | }, 339 | &cli.StringFlag{ 340 | Name: "output", 341 | Value: "json", 342 | Usage: "output format", 343 | }, 344 | }, 345 | Action: func(c *cli.Context) error { 346 | opts := getOpts(c) 347 | // useStdOut = opts.useStdOut 348 | 349 | return processGetTrash(c, opts) 350 | }, 351 | }, 352 | }, 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /cmd/sncli/healthcheck.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jonhadfield/gosn-v2/session" 7 | sncli "github.com/jonhadfield/sn-cli" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func cmdHealthcheck() *cli.Command { 12 | return &cli.Command{ 13 | Name: "healthcheck", 14 | Usage: "find and fix account data errors", 15 | Hidden: true, 16 | BashComplete: func(c *cli.Context) { 17 | addTasks := []string{"keys"} 18 | if c.NArg() > 0 { 19 | return 20 | } 21 | for _, t := range addTasks { 22 | fmt.Println(t) 23 | } 24 | }, 25 | Subcommands: []*cli.Command{ 26 | { 27 | Name: "keys", 28 | Usage: "find issues relating to ItemsKeys", 29 | BashComplete: func(c *cli.Context) { 30 | hcKeysOpts := []string{"--delete-invalid"} 31 | if c.NArg() > 0 { 32 | return 33 | } 34 | for _, ano := range hcKeysOpts { 35 | fmt.Println(ano) 36 | } 37 | }, 38 | Flags: []cli.Flag{ 39 | &cli.BoolFlag{ 40 | Hidden: true, 41 | Name: "delete-invalid", 42 | Usage: "delete items that cannot be decrypted", 43 | }, 44 | }, 45 | Action: func(c *cli.Context) error { 46 | opts := getOpts(c) 47 | // useStdOut = opts.useStdOut 48 | 49 | var sess session.Session 50 | 51 | sess, _, err := session.GetSession(nil, opts.useSession, opts.sessKey, opts.server, opts.debug) 52 | if err != nil { 53 | return err 54 | } 55 | err = sncli.ItemKeysHealthcheck(sncli.ItemsKeysHealthcheckInput{ 56 | Session: sess, 57 | UseStdOut: opts.useStdOut, 58 | DeleteInvalid: c.Bool("delete-invalid"), 59 | }) 60 | 61 | return err 62 | }, 63 | }, 64 | }, 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /cmd/sncli/item.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/jonhadfield/gosn-v2/cache" 9 | "github.com/jonhadfield/gosn-v2/common" 10 | "github.com/jonhadfield/gosn-v2/items" 11 | sncli "github.com/jonhadfield/sn-cli" 12 | "github.com/urfave/cli/v2" 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | func processGetItems(c *cli.Context, opts configOptsOutput) (err error) { 17 | inUUID := strings.TrimSpace(c.String("uuid")) 18 | 19 | matchAny := true 20 | if c.Bool("match-all") { 21 | matchAny = false 22 | } 23 | 24 | getItemsIF := items.ItemFilters{ 25 | Filters: []items.Filter{ 26 | { 27 | Key: "uuid", 28 | Comparison: "==", 29 | Value: inUUID, 30 | }, 31 | }, 32 | MatchAny: matchAny, 33 | } 34 | 35 | var sess cache.Session 36 | 37 | sess, _, err = cache.GetSession(common.NewHTTPClient(), opts.useSession, opts.sessKey, opts.server, opts.debug) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | // TODO: validate output 43 | output := c.String("output") 44 | 45 | var cacheDBPath string 46 | 47 | cacheDBPath, err = cache.GenCacheDBPath(sess, opts.cacheDBDir, snAppName) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | sess.CacheDBPath = cacheDBPath 53 | appGetItemsConfig := sncli.GetItemsConfig{ 54 | Session: &sess, 55 | Filters: getItemsIF, 56 | Output: output, 57 | Debug: opts.debug, 58 | } 59 | 60 | var rawItems items.Items 61 | 62 | rawItems, err = appGetItemsConfig.Run() 63 | if err != nil { 64 | return err 65 | } 66 | 67 | // strip deleted items 68 | rawItems = sncli.RemoveDeleted(rawItems) 69 | numResults := len(rawItems) 70 | 71 | if numResults == 0 { 72 | // msg = msgNoMatches 73 | 74 | return nil 75 | } 76 | 77 | output = c.String("output") 78 | 79 | var bOutput []byte 80 | 81 | switch strings.ToLower(output) { 82 | case "json": 83 | bOutput, err = json.MarshalIndent(rawItems, "", " ") 84 | case "yaml": 85 | bOutput, err = yaml.Marshal(rawItems) 86 | } 87 | 88 | if len(bOutput) > 0 { 89 | fmt.Print("{\n \"items\": ") 90 | fmt.Print(string(bOutput)) 91 | fmt.Print("\n}") 92 | } 93 | 94 | return err 95 | } 96 | -------------------------------------------------------------------------------- /cmd/sncli/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "sort" 8 | "syscall" 9 | "time" 10 | 11 | sncli "github.com/jonhadfield/sn-cli" 12 | "github.com/spf13/viper" 13 | "github.com/urfave/cli/v2" 14 | "golang.org/x/term" 15 | ) 16 | 17 | const ( 18 | snAppName = "sn-cli" 19 | msgAddSuccess = "added" 20 | msgDeleted = "deleted" 21 | msgMultipleNotesFoundWithSameTitle = "multiple notes found with the same title" 22 | msgNoteAdded = "note added" 23 | msgNoteDeleted = "note deleted" 24 | msgNoteNotFound = "note not found" 25 | msgTagSuccess = "item tagged" 26 | msgTagAlreadyExists = "tag already exists" 27 | msgTagAdded = "tag added" 28 | msgTagDeleted = "tag deleted" 29 | msgFailedToDeleteTag = "failed to delete tag" 30 | msgTagNotFound = "tag not found" 31 | msgItemsDeleted = "items deleted" 32 | msgNoMatches = "no matches" 33 | msgRegisterSuccess = "registered" 34 | ) 35 | 36 | var yamlAbbrevs = []string{"yml", "yaml"} 37 | 38 | // overwritten at build time. 39 | var version, versionOutput, tag, sha, buildDate string 40 | 41 | func main() { 42 | if err := startCLI(os.Args); err != nil { 43 | fmt.Println(err) 44 | os.Exit(1) 45 | } 46 | 47 | os.Exit(0) 48 | } 49 | 50 | type configOptsOutput struct { 51 | useStdOut bool 52 | useSession bool 53 | sessKey string 54 | server string 55 | cacheDBDir string 56 | debug bool 57 | } 58 | 59 | func getOpts(c *cli.Context) (out configOptsOutput) { 60 | out.useStdOut = true 61 | 62 | if c.Bool("no-stdout") { 63 | out.useStdOut = false 64 | } 65 | 66 | if c.Bool("use-session") || viper.GetBool("use_session") { 67 | out.useSession = true 68 | } 69 | 70 | out.sessKey = c.String("session-key") 71 | 72 | out.server = c.String("server") 73 | 74 | if viper.GetString("server") != "" { 75 | out.server = viper.GetString("server") 76 | } 77 | 78 | out.cacheDBDir = viper.GetString("cachedb_dir") 79 | if out.cacheDBDir != "" { 80 | out.cacheDBDir = c.String("cachedb-dir") 81 | } 82 | 83 | if c.Bool("debug") { 84 | out.debug = true 85 | } 86 | 87 | return 88 | } 89 | 90 | func appSetup() (app *cli.App) { 91 | viper.SetEnvPrefix("sn") 92 | viper.AutomaticEnv() 93 | 94 | if tag != "" && buildDate != "" { 95 | versionOutput = fmt.Sprintf("[%s-%s] %s", tag, sha, buildDate) 96 | } else { 97 | versionOutput = version 98 | } 99 | 100 | app = cli.NewApp() 101 | app.EnableBashCompletion = true 102 | 103 | app.Name = "sn" 104 | app.Version = versionOutput 105 | app.Compiled = time.Now() 106 | app.Authors = []*cli.Author{ 107 | { 108 | Name: "Jon Hadfield", 109 | Email: "jon@lessknown.co.uk", 110 | }, 111 | } 112 | app.HelpName = "-" 113 | app.Usage = "Standard Notes CLI" 114 | app.Description = "" 115 | app.BashComplete = func(c *cli.Context) { 116 | for _, cmd := range c.App.Commands { 117 | if !cmd.Hidden { 118 | fmt.Fprintln(c.App.Writer, cmd.Name) 119 | } 120 | } 121 | } 122 | app.Flags = []cli.Flag{ 123 | &cli.BoolFlag{Name: "debug", Value: viper.GetBool("debug")}, 124 | &cli.StringFlag{Name: "server", Value: viper.GetString("server")}, 125 | &cli.BoolFlag{Name: "use-session", Value: viper.GetBool("use_session")}, 126 | &cli.StringFlag{Name: "session-key"}, 127 | &cli.BoolFlag{Name: "no-stdout", Hidden: true}, 128 | &cli.StringFlag{Name: "cachedb-dir", Value: viper.GetString("cachedb_dir")}, 129 | } 130 | app.Commands = []*cli.Command{ 131 | cmdAdd(), 132 | cmdDebug(), 133 | cmdDelete(), 134 | cmdEdit(), 135 | cmdGet(), 136 | cmdHealthcheck(), 137 | cmdRegister(), 138 | cmdResync(), 139 | cmdSession(), 140 | cmdStats(), 141 | cmdTask(), 142 | cmdTag(), 143 | cmdWipe(), 144 | } 145 | 146 | app.CommandNotFound = func(c *cli.Context, command string) { 147 | _, _ = fmt.Fprintf(c.App.Writer, "\ninvalid command: \"%s\" \n\n", command) 148 | cli.ShowAppHelpAndExit(c, 1) 149 | } 150 | 151 | return app 152 | } 153 | 154 | func startCLI(args []string) (err error) { 155 | app := appSetup() 156 | 157 | sort.Sort(cli.FlagsByName(app.Flags)) 158 | 159 | return app.Run(args) 160 | } 161 | 162 | func getPassword() (res string, err error) { 163 | for { 164 | fmt.Print("password: ") 165 | var bytePassword []byte 166 | if bytePassword, err = term.ReadPassword(int(syscall.Stdin)); err != nil { 167 | return 168 | } 169 | 170 | if len(bytePassword) < sncli.MinPasswordLength { 171 | err = fmt.Errorf("\rpassword must be at least %d characters", sncli.MinPasswordLength) 172 | 173 | return 174 | } 175 | 176 | var bytePassword2 []byte 177 | fmt.Printf("\rconfirm password: ") 178 | if bytePassword2, err = term.ReadPassword(int(syscall.Stdin)); err != nil { 179 | return 180 | } 181 | 182 | if !bytes.Equal(bytePassword, bytePassword2) { 183 | fmt.Printf("\rpasswords do not match") 184 | fmt.Println() 185 | 186 | return 187 | } 188 | 189 | fmt.Println() 190 | if err == nil { 191 | res = string(bytePassword) 192 | 193 | return 194 | } 195 | } 196 | } 197 | 198 | func numTrue(in ...bool) (total int) { 199 | for _, i := range in { 200 | if i { 201 | total++ 202 | } 203 | } 204 | 205 | return 206 | } 207 | -------------------------------------------------------------------------------- /cmd/sncli/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/jonhadfield/gosn-v2/auth" 12 | "github.com/jonhadfield/gosn-v2/cache" 13 | "github.com/jonhadfield/gosn-v2/common" 14 | "github.com/jonhadfield/gosn-v2/items" 15 | "github.com/jonhadfield/gosn-v2/session" 16 | ) 17 | 18 | var ( 19 | testSession *cache.Session 20 | gTtestSession *session.Session 21 | testUserEmail string 22 | testUserPassword string 23 | ) 24 | 25 | func localTestMain() { 26 | localServer := "http://ramea:3000" 27 | testUserEmail = fmt.Sprintf("ramea-%s", strconv.FormatInt(time.Now().UnixNano(), 16)) 28 | testUserPassword = "secretsanta" 29 | 30 | rInput := auth.RegisterInput{ 31 | Password: testUserPassword, 32 | Email: testUserEmail, 33 | APIServer: localServer, 34 | Version: "004", 35 | // Debug: true, 36 | } 37 | 38 | _, err := rInput.Register() 39 | if err != nil { 40 | panic(fmt.Sprintf("failed to register with: %s", localServer)) 41 | } 42 | 43 | signIn(localServer, testUserEmail, testUserPassword) 44 | } 45 | 46 | func signIn(server, email, password string) { 47 | ts, err := auth.CliSignIn(email, password, server, false) 48 | if err != nil { 49 | fmt.Println(err) 50 | 51 | os.Exit(1) 52 | } 53 | 54 | if server == "" { 55 | server = session.SNServerURL 56 | } 57 | 58 | httpClient := common.NewHTTPClient() 59 | 60 | debug := false 61 | if !debug { 62 | httpClient.Logger = nil 63 | } 64 | 65 | ts.HTTPClient = httpClient 66 | if httpClient.Logger != nil { 67 | panic("httpClient.Logger should be nil") 68 | } 69 | 70 | gTtestSession = &session.Session{ 71 | Debug: debug, 72 | HTTPClient: httpClient, 73 | SchemaValidation: false, 74 | Server: server, 75 | FilesServerUrl: ts.FilesServerUrl, 76 | Token: "", 77 | MasterKey: ts.MasterKey, 78 | ItemsKeys: nil, 79 | DefaultItemsKey: session.SessionItemsKey{}, 80 | KeyParams: auth.KeyParams{}, 81 | AccessToken: ts.AccessToken, 82 | RefreshToken: ts.RefreshToken, 83 | AccessExpiration: ts.AccessExpiration, 84 | RefreshExpiration: ts.RefreshExpiration, 85 | ReadOnlyAccess: ts.ReadOnlyAccess, 86 | PasswordNonce: ts.PasswordNonce, 87 | Schemas: nil, 88 | } 89 | 90 | testSession = &cache.Session{ 91 | Session: gTtestSession, 92 | CacheDB: nil, 93 | CacheDBPath: "", 94 | } 95 | } 96 | 97 | func TestMain(m *testing.M) { 98 | // if os.Getenv("SN_SERVER") == "" || strings.Contains(os.Getenv("SN_SERVER"), "ramea") { 99 | if strings.Contains(os.Getenv("SN_SERVER"), "ramea") { 100 | localTestMain() 101 | } else { 102 | signIn(session.SNServerURL, os.Getenv("SN_EMAIL"), os.Getenv("SN_PASSWORD")) 103 | } 104 | 105 | if _, err := items.Sync(items.SyncInput{Session: gTtestSession}); err != nil { 106 | fmt.Println(err) 107 | 108 | os.Exit(1) 109 | } 110 | 111 | if gTtestSession.DefaultItemsKey.ItemsKey == "" { 112 | panic("failed in TestMain due to empty default items key") 113 | } 114 | if strings.TrimSpace(gTtestSession.Server) == "" { 115 | panic("failed in TestMain due to empty server") 116 | } 117 | 118 | var err error 119 | testSession, err = cache.ImportSession(&auth.SignInResponseDataSession{ 120 | Debug: gTtestSession.Debug, 121 | HTTPClient: gTtestSession.HTTPClient, 122 | SchemaValidation: false, 123 | Server: gTtestSession.Server, 124 | FilesServerUrl: gTtestSession.FilesServerUrl, 125 | Token: "", 126 | MasterKey: gTtestSession.MasterKey, 127 | KeyParams: gTtestSession.KeyParams, 128 | AccessToken: gTtestSession.AccessToken, 129 | RefreshToken: gTtestSession.RefreshToken, 130 | AccessExpiration: gTtestSession.AccessExpiration, 131 | RefreshExpiration: gTtestSession.RefreshExpiration, 132 | ReadOnlyAccess: gTtestSession.ReadOnlyAccess, 133 | PasswordNonce: gTtestSession.PasswordNonce, 134 | }, "") 135 | if err != nil { 136 | return 137 | } 138 | 139 | testSession.CacheDBPath, err = cache.GenCacheDBPath(*testSession, "", common.LibName) 140 | if err != nil { 141 | panic(err) 142 | } 143 | 144 | os.Exit(m.Run()) 145 | } 146 | -------------------------------------------------------------------------------- /cmd/sncli/note_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestAddDeleteNote(t *testing.T) { 13 | time.Sleep(250 * time.Millisecond) 14 | 15 | var outputBuffer bytes.Buffer 16 | 17 | app := appSetup() 18 | 19 | app.Writer = &outputBuffer 20 | 21 | osArgs := []string{"sncli", "add", "note", "--title", "testNote", "--text", "testAddNote"} 22 | 23 | err := app.Run(osArgs) 24 | require.NoError(t, err) 25 | 26 | stdout := outputBuffer.String() 27 | 28 | fmt.Println(stdout) 29 | require.NoError(t, err) 30 | require.Contains(t, stdout, msgAddSuccess) 31 | 32 | outputBuffer.Reset() 33 | osArgs = []string{"sncli", "delete", "note", "--title", "testNote"} 34 | err = app.Run(osArgs) 35 | stdout = outputBuffer.String() 36 | fmt.Println(stdout) 37 | require.NoError(t, err) 38 | require.Contains(t, stdout, msgDeleted) 39 | } 40 | 41 | func TestGetMissingNote(t *testing.T) { 42 | time.Sleep(250 * time.Millisecond) 43 | var outputBuffer bytes.Buffer 44 | app := appSetup() 45 | 46 | app.Writer = &outputBuffer 47 | osArgs := []string{"sncli", "get", "note", "--title", "missing note"} 48 | 49 | err := app.Run(osArgs) 50 | require.NoError(t, err) 51 | 52 | stdout := outputBuffer.String() 53 | fmt.Println(stdout) 54 | require.NoError(t, err) 55 | require.Contains(t, stdout, msgNoMatches) 56 | } 57 | 58 | func TestDeleteNonExistantNote(t *testing.T) { 59 | time.Sleep(250 * time.Millisecond) 60 | var outputBuffer bytes.Buffer 61 | app := appSetup() 62 | app.Writer = &outputBuffer 63 | 64 | outputBuffer.Reset() 65 | require.NoError(t, app.Run([]string{"sncli", "delete", "note", "--title", "testNote"})) 66 | 67 | stdout := outputBuffer.String() 68 | fmt.Println(stdout) 69 | require.Contains(t, stdout, msgNoteNotFound) 70 | } 71 | -------------------------------------------------------------------------------- /cmd/sncli/register.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | sncli "github.com/jonhadfield/sn-cli" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func cmdRegister() *cli.Command { 13 | return &cli.Command{ 14 | Name: "register", 15 | Usage: "register a new user", 16 | BashComplete: func(c *cli.Context) { 17 | if c.NArg() > 0 { 18 | return 19 | } 20 | 21 | for _, t := range []string{"--email"} { 22 | fmt.Println(t) 23 | } 24 | }, 25 | Flags: []cli.Flag{ 26 | &cli.StringFlag{ 27 | Name: "email", 28 | Usage: "email address", 29 | }, 30 | }, 31 | Action: func(c *cli.Context) error { 32 | opts := getOpts(c) 33 | 34 | var err error 35 | if strings.TrimSpace(c.String("email")) == "" { 36 | if err = cli.ShowCommandHelp(c, "register"); err != nil { 37 | panic(err) 38 | } 39 | 40 | return errors.New("email required") 41 | } 42 | 43 | var password string 44 | if password, err = getPassword(); err != nil { 45 | return err 46 | } 47 | 48 | registerConfig := sncli.RegisterConfig{ 49 | Email: c.String("email"), 50 | Password: password, 51 | APIServer: opts.server, 52 | Debug: opts.debug, 53 | } 54 | if err = registerConfig.Run(); err != nil { 55 | return err 56 | } 57 | 58 | fmt.Println(msgRegisterSuccess) 59 | 60 | return nil 61 | }, 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /cmd/sncli/resync.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jonhadfield/gosn-v2/cache" 5 | "github.com/jonhadfield/gosn-v2/common" 6 | sncli "github.com/jonhadfield/sn-cli" 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | func cmdResync() *cli.Command { 11 | return &cli.Command{ 12 | Name: "resync", 13 | Usage: "purge cache and resync content", 14 | BashComplete: func(c *cli.Context) { 15 | return 16 | }, 17 | Action: func(c *cli.Context) error { 18 | opts := getOpts(c) 19 | 20 | session, _, err := cache.GetSession(common.NewHTTPClient(), opts.useSession, opts.sessKey, opts.server, opts.debug) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | return sncli.Resync(&session, opts.cacheDBDir, snAppName) 26 | }, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /cmd/sncli/session.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/jonhadfield/gosn-v2/session" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func cmdSession() *cli.Command { 12 | return &cli.Command{ 13 | Name: "session", 14 | Usage: "manage session credentials", 15 | Flags: []cli.Flag{ 16 | &cli.BoolFlag{ 17 | Name: "add", 18 | Usage: "add session to keychain", 19 | }, 20 | &cli.BoolFlag{ 21 | Name: "remove", 22 | Usage: "remove session from keychain", 23 | }, 24 | &cli.BoolFlag{ 25 | Name: "status", 26 | Usage: "get session details", 27 | }, 28 | &cli.StringFlag{ 29 | Name: "session-key", 30 | Usage: "[optional] key to encrypt/decrypt session", 31 | Required: false, 32 | }, 33 | }, 34 | BashComplete: func(c *cli.Context) { 35 | if c.NArg() > 0 { 36 | return 37 | } 38 | 39 | for _, t := range []string{"--add", "--remove", "--status", "--session-key"} { 40 | fmt.Println(t) 41 | } 42 | }, 43 | Action: func(c *cli.Context) error { 44 | opts := getOpts(c) 45 | 46 | return processSession(c, opts) 47 | }, 48 | } 49 | } 50 | 51 | func processSession(c *cli.Context, opts configOptsOutput) (err error) { 52 | sAdd := c.Bool("add") 53 | sRemove := c.Bool("remove") 54 | sStatus := c.Bool("status") 55 | sessKey := c.String("session-key") 56 | 57 | if sStatus || sRemove { 58 | if err = session.SessionExists(nil); err != nil { 59 | return err 60 | } 61 | } 62 | 63 | nTrue := numTrue(sAdd, sRemove, sStatus) 64 | if nTrue == 0 || nTrue > 1 { 65 | _ = cli.ShowCommandHelp(c, "session") 66 | 67 | os.Exit(1) 68 | } 69 | 70 | if sAdd { 71 | var msg string 72 | 73 | msg, err = session.AddSession(nil, opts.server, sessKey, nil, opts.debug) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | _, _ = fmt.Fprint(c.App.Writer, msg) 79 | 80 | return nil 81 | } 82 | 83 | if sRemove { 84 | msg := session.RemoveSession(nil) 85 | _, _ = fmt.Fprint(c.App.Writer, msg) 86 | 87 | return nil 88 | } 89 | 90 | if sStatus { 91 | var msg string 92 | 93 | msg, err = session.SessionStatus(sessKey, nil) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | _, _ = fmt.Fprint(c.App.Writer, msg+"\n") 99 | } 100 | 101 | return err 102 | } 103 | 104 | // { 105 | // Name: "output-session", 106 | // Usage: "returns specified session items", 107 | // BashComplete: func(c *cli.Context) { 108 | // hcKeysOpts := []string{"--master-key"} 109 | // if c.NArg() > 0 { 110 | // return 111 | // } 112 | // for _, ano := range hcKeysOpts { 113 | // fmt.Println(ano) 114 | // } 115 | // }, 116 | // Flags: []cli.Flag{ 117 | // &cli.BoolFlag{ 118 | // Name: "master-key", 119 | // Usage: "output master key", 120 | // }, 121 | // }, 122 | // Action: func(c *cli.Context) error { 123 | // var opts configOptsOutput 124 | // opts := getOpts(c) 125 | // if err != nil { 126 | // return err 127 | // } 128 | // // useStdOut = opts.useStdOut 129 | // 130 | // var sess session.Session 131 | // 132 | // sess, _, err = session.GetSession(opts.useSession, opts.sessKey, opts.server, opts.debug) 133 | // 134 | // if err != nil { 135 | // return err 136 | // } 137 | // err = sncli.OutputSession(sncli.OutputSessionInput{ 138 | // Session: sess, 139 | // UseStdOut: opts.useStdOut, 140 | // OutputMasterKey: c.Bool("master-key"), 141 | // }) 142 | // 143 | // return err 144 | // }, 145 | // }, 146 | -------------------------------------------------------------------------------- /cmd/sncli/stats.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jonhadfield/gosn-v2/cache" 5 | "github.com/jonhadfield/gosn-v2/common" 6 | sncli "github.com/jonhadfield/sn-cli" 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | func cmdStats() *cli.Command { 11 | return &cli.Command{ 12 | Name: "stats", 13 | Usage: "show statistics", 14 | BashComplete: func(c *cli.Context) { 15 | if c.NArg() > 0 { 16 | return 17 | } 18 | }, 19 | Action: func(c *cli.Context) error { 20 | opts := getOpts(c) 21 | 22 | sess, _, err := cache.GetSession(common.NewHTTPClient(), opts.useSession, opts.sessKey, opts.server, opts.debug) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | sess.CacheDBPath, err = cache.GenCacheDBPath(sess, opts.cacheDBDir, snAppName) 28 | if err != nil { 29 | return err 30 | } 31 | 32 | statsConfig := sncli.StatsConfig{ 33 | Session: sess, 34 | } 35 | 36 | return statsConfig.Run() 37 | }, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /cmd/sncli/tag.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "strings" 10 | 11 | "github.com/asdine/storm/v3/q" 12 | "github.com/gookit/color" 13 | "github.com/jonhadfield/gosn-v2/cache" 14 | "github.com/jonhadfield/gosn-v2/common" 15 | "github.com/jonhadfield/gosn-v2/items" 16 | sncli "github.com/jonhadfield/sn-cli" 17 | "github.com/urfave/cli/v2" 18 | "gopkg.in/yaml.v2" 19 | ) 20 | 21 | func getTagByUUID(sess *cache.Session, uuid string) (tag items.Tag, err error) { 22 | if sess.CacheDBPath == "" { 23 | return tag, errors.New("CacheDBPath missing from sess") 24 | } 25 | 26 | if uuid == "" { 27 | return tag, errors.New("uuid not supplied") 28 | } 29 | 30 | var so cache.SyncOutput 31 | 32 | si := cache.SyncInput{ 33 | Session: sess, 34 | Close: false, 35 | } 36 | 37 | so, err = cache.Sync(si) 38 | if err != nil { 39 | return 40 | } 41 | 42 | defer func() { 43 | _ = so.DB.Close() 44 | }() 45 | 46 | var encTags cache.Items 47 | 48 | query := so.DB.Select(q.And(q.Eq("UUID", uuid), q.Eq("Deleted", false))) 49 | if err = query.Find(&encTags); err != nil { 50 | if strings.Contains(err.Error(), "not found") { 51 | return tag, errors.New(fmt.Sprintf("could not find tag with UUID %s", uuid)) 52 | } 53 | 54 | return 55 | } 56 | 57 | var rawEncItems items.Items 58 | rawEncItems, err = encTags.ToItems(sess) 59 | 60 | return *rawEncItems[0].(*items.Tag), err 61 | } 62 | 63 | func getTagsByTitle(sess cache.Session, title string) (tags items.Tags, err error) { 64 | var so cache.SyncOutput 65 | 66 | si := cache.SyncInput{ 67 | Session: &sess, 68 | Close: false, 69 | } 70 | 71 | so, err = cache.Sync(si) 72 | if err != nil { 73 | return 74 | } 75 | 76 | defer func() { 77 | _ = so.DB.Close() 78 | }() 79 | 80 | var allEncTags cache.Items 81 | 82 | query := so.DB.Select(q.And(q.Eq("ContentType", common.SNItemTypeTag), q.Eq("Deleted", false))) 83 | 84 | err = query.Find(&allEncTags) 85 | if err != nil { 86 | if strings.Contains(err.Error(), "not found") { 87 | return nil, fmt.Errorf("could not find any tags") 88 | } 89 | 90 | return 91 | } 92 | 93 | // decrypt all tags 94 | var allRawTags items.Items 95 | allRawTags, err = allEncTags.ToItems(&sess) 96 | 97 | var matchingRawTags items.Tags 98 | 99 | for _, rt := range allRawTags { 100 | t := rt.(*items.Tag) 101 | if t.Content.Title == title { 102 | matchingRawTags = append(matchingRawTags, *t) 103 | } 104 | } 105 | 106 | return matchingRawTags, err 107 | } 108 | 109 | func processEditTag(c *cli.Context, opts configOptsOutput) (err error) { 110 | inUUID := c.String("uuid") 111 | inTitle := c.String("title") 112 | 113 | if inTitle == "" && inUUID == "" || inTitle != "" && inUUID != "" { 114 | _ = cli.ShowSubcommandHelp(c) 115 | 116 | return errors.New("title or UUID is required") 117 | } 118 | 119 | var sess cache.Session 120 | 121 | sess, _, err = cache.GetSession(common.NewHTTPClient(), opts.useSession, opts.sessKey, opts.server, opts.debug) 122 | if err != nil { 123 | return err 124 | } 125 | 126 | var cacheDBPath string 127 | 128 | cacheDBPath, err = cache.GenCacheDBPath(sess, opts.cacheDBDir, snAppName) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | sess.CacheDBPath = cacheDBPath 134 | 135 | var tagToEdit items.Tag 136 | 137 | var tags items.Tags 138 | 139 | // if uuid was passed then retrieve tagToEdit from db using uuid 140 | if inUUID != "" { 141 | if tagToEdit, err = getTagByUUID(&sess, inUUID); err != nil { 142 | return 143 | } 144 | } 145 | 146 | // if title was passed then retrieve tagToEdit(s) matching that title 147 | if inTitle != "" { 148 | if tags, err = getTagsByTitle(sess, inTitle); err != nil { 149 | return 150 | } 151 | 152 | if len(tags) == 0 { 153 | return errors.New("tagToEdit not found") 154 | } 155 | 156 | if len(tags) > 1 { 157 | return errors.New("multiple tags found with same title") 158 | } 159 | 160 | tagToEdit = tags[0] 161 | } 162 | 163 | // only show existing title information if uuid was passed 164 | if inUUID != "" { 165 | fmt.Printf("existing title: %s\n", tagToEdit.Content.Title) 166 | } 167 | 168 | reader := bufio.NewReader(os.Stdin) 169 | 170 | fmt.Print("new title: ") 171 | 172 | text, _ := reader.ReadString('\n') 173 | 174 | text = strings.TrimSuffix(text, "\n") 175 | if len(text) == 0 { 176 | return errors.New("new tagToEdit title not entered") 177 | } 178 | 179 | tagToEdit.Content.Title = text 180 | 181 | si := cache.SyncInput{ 182 | Session: &sess, 183 | Close: false, 184 | } 185 | 186 | var so cache.SyncOutput 187 | 188 | so, err = cache.Sync(si) 189 | if err != nil { 190 | return 191 | } 192 | 193 | tags = items.Tags{tagToEdit} 194 | 195 | if err = cache.SaveTags(so.DB, &sess, tags, true); err != nil { 196 | return 197 | } 198 | 199 | if _, err = cache.Sync(si); err != nil { 200 | return 201 | } 202 | 203 | _, _ = fmt.Fprint(c.App.Writer, color.Green.Sprintf("tag updated")) 204 | 205 | return nil 206 | } 207 | 208 | func processGetTags(c *cli.Context, opts configOptsOutput) (err error) { 209 | inTitle := strings.TrimSpace(c.String("title")) 210 | inUUID := strings.TrimSpace(c.String("uuid")) 211 | 212 | matchAny := true 213 | if c.Bool("match-all") { 214 | matchAny = false 215 | } 216 | 217 | regex := c.Bool("regex") 218 | count := c.Bool("count") 219 | 220 | getTagsIF := items.ItemFilters{ 221 | MatchAny: matchAny, 222 | } 223 | 224 | // add uuid filters 225 | if inUUID != "" { 226 | for _, uuid := range sncli.CommaSplit(inUUID) { 227 | titleFilter := items.Filter{ 228 | Type: common.SNItemTypeTag, 229 | Key: "uuid", 230 | Comparison: "==", 231 | Value: uuid, 232 | } 233 | getTagsIF.Filters = append(getTagsIF.Filters, titleFilter) 234 | } 235 | } 236 | 237 | comparison := "contains" 238 | if regex { 239 | comparison = "~" 240 | } 241 | 242 | if inTitle != "" { 243 | for _, title := range sncli.CommaSplit(inTitle) { 244 | titleFilter := items.Filter{ 245 | Type: common.SNItemTypeTag, 246 | Key: "Title", 247 | Comparison: comparison, 248 | Value: title, 249 | } 250 | getTagsIF.Filters = append(getTagsIF.Filters, titleFilter) 251 | } 252 | } 253 | 254 | if inTitle == "" && inUUID == "" { 255 | getTagsIF.Filters = append(getTagsIF.Filters, items.Filter{ 256 | Type: common.SNItemTypeTag, 257 | }) 258 | } 259 | 260 | var sess cache.Session 261 | 262 | sess, _, err = cache.GetSession(common.NewHTTPClient(), opts.useSession, opts.sessKey, opts.server, opts.debug) 263 | if err != nil { 264 | return err 265 | } 266 | 267 | // TODO: validate output 268 | output := c.String("output") 269 | 270 | var cacheDBPath string 271 | 272 | cacheDBPath, err = cache.GenCacheDBPath(sess, opts.cacheDBDir, snAppName) 273 | if err != nil { 274 | return err 275 | } 276 | 277 | sess.CacheDBPath = cacheDBPath 278 | 279 | appGetTagConfig := sncli.GetTagConfig{ 280 | Session: &sess, 281 | Filters: getTagsIF, 282 | Output: output, 283 | Debug: opts.debug, 284 | } 285 | 286 | var rawTags items.Items 287 | 288 | rawTags, err = appGetTagConfig.Run() 289 | if err != nil { 290 | return err 291 | } 292 | 293 | // strip deleted items 294 | rawTags = sncli.RemoveDeleted(rawTags) 295 | 296 | if len(rawTags) == 0 { 297 | _, _ = fmt.Fprint(c.App.Writer, color.Green.Sprintf(msgNoMatches)) 298 | 299 | return nil 300 | } 301 | 302 | var tagsYAML []sncli.TagYAML 303 | 304 | var tagsJSON []sncli.TagJSON 305 | 306 | var numResults int 307 | 308 | for _, rt := range rawTags { 309 | numResults++ 310 | 311 | if !count && sncli.StringInSlice(output, yamlAbbrevs, false) { 312 | tagContentOrgStandardNotesSNDetailYAML := sncli.OrgStandardNotesSNDetailYAML{ 313 | ClientUpdatedAt: rt.(*items.Tag).Content.GetAppData().OrgStandardNotesSN.ClientUpdatedAt, 314 | } 315 | tagContentAppDataContent := sncli.AppDataContentYAML{ 316 | OrgStandardNotesSN: tagContentOrgStandardNotesSNDetailYAML, 317 | } 318 | 319 | tagContentYAML := sncli.TagContentYAML{ 320 | Title: rt.(*items.Tag).Content.GetTitle(), 321 | ItemReferences: sncli.ItemRefsToYaml(rt.(*items.Tag).Content.References()), 322 | AppData: tagContentAppDataContent, 323 | } 324 | 325 | tagsYAML = append(tagsYAML, sncli.TagYAML{ 326 | UUID: rt.(*items.Tag).UUID, 327 | ContentType: rt.(*items.Tag).ContentType, 328 | Content: tagContentYAML, 329 | UpdatedAt: rt.(*items.Tag).UpdatedAt, 330 | CreatedAt: rt.(*items.Tag).CreatedAt, 331 | }) 332 | } 333 | 334 | if !count && strings.ToLower(output) == "json" { 335 | tagContentOrgStandardNotesSNDetailJSON := sncli.OrgStandardNotesSNDetailJSON{ 336 | ClientUpdatedAt: rt.(*items.Tag).Content.GetAppData().OrgStandardNotesSN.ClientUpdatedAt, 337 | } 338 | tagContentAppDataContent := sncli.AppDataContentJSON{ 339 | OrgStandardNotesSN: tagContentOrgStandardNotesSNDetailJSON, 340 | } 341 | 342 | tagContentJSON := sncli.TagContentJSON{ 343 | Title: rt.(*items.Tag).Content.GetTitle(), 344 | ItemReferences: sncli.ItemRefsToJSON(rt.(*items.Tag).Content.References()), 345 | AppData: tagContentAppDataContent, 346 | } 347 | 348 | tagsJSON = append(tagsJSON, sncli.TagJSON{ 349 | UUID: rt.(*items.Tag).UUID, 350 | ContentType: rt.(*items.Tag).ContentType, 351 | Content: tagContentJSON, 352 | UpdatedAt: rt.(*items.Tag).UpdatedAt, 353 | CreatedAt: rt.(*items.Tag).CreatedAt, 354 | }) 355 | } 356 | } 357 | // if !opts.useStdOut { 358 | // return 359 | // } else if numResults <= 0 { 360 | // if count { 361 | // msg = "0" 362 | // } else { 363 | // msg = msgNoMatches 364 | // } 365 | // } else if count { 366 | // msg = strconv.Itoa(numResults) 367 | // } else { 368 | output = c.String("output") 369 | var bOutput []byte 370 | switch strings.ToLower(output) { 371 | case "json": 372 | bOutput, err = json.MarshalIndent(tagsJSON, "", " ") 373 | case "yaml": 374 | bOutput, err = yaml.Marshal(tagsYAML) 375 | } 376 | 377 | if len(bOutput) > 0 { 378 | fmt.Print("{\n \"tags\": ") 379 | fmt.Print(string(bOutput)) 380 | fmt.Print("\n}") 381 | } 382 | 383 | return nil 384 | } 385 | 386 | func processAddTags(c *cli.Context, opts configOptsOutput) (err error) { 387 | // validate input 388 | tagInput := c.String("title") 389 | if strings.TrimSpace(tagInput) == "" { 390 | _ = cli.ShowSubcommandHelp(c) 391 | 392 | return errors.New("tag title not defined") 393 | } 394 | 395 | // get session 396 | session, _, err := cache.GetSession(common.NewHTTPClient(), opts.useSession, opts.sessKey, opts.server, opts.debug) 397 | if err != nil { 398 | return err 399 | } 400 | 401 | session.CacheDBPath, err = cache.GenCacheDBPath(session, opts.cacheDBDir, snAppName) 402 | if err != nil { 403 | return err 404 | } 405 | 406 | // prepare input 407 | tags := sncli.CommaSplit(tagInput) 408 | addTagInput := sncli.AddTagsInput{ 409 | Session: &session, 410 | Tags: tags, 411 | Parent: c.String("parent"), 412 | ParentUUID: c.String("parent-uuid"), 413 | Debug: opts.debug, 414 | } 415 | 416 | // attempt to add tags 417 | var ato sncli.AddTagsOutput 418 | 419 | ato, err = addTagInput.Run() 420 | if err != nil { 421 | _, _ = fmt.Fprint(c.App.Writer, color.Red.Sprint(err.Error())) 422 | return err 423 | } 424 | 425 | var msg string 426 | // present results 427 | if len(ato.Added) > 0 { 428 | _, _ = fmt.Fprint(c.App.Writer, color.Green.Sprint(msgTagAdded+": ", strings.Join(ato.Added, ", "), "\n")) 429 | 430 | return err 431 | } 432 | 433 | if len(ato.Existing) > 0 { 434 | // add line break if output already added 435 | if len(msg) > 0 { 436 | msg += "\n" 437 | } 438 | 439 | _, _ = fmt.Fprint(c.App.Writer, color.Yellow.Sprint(msgTagAlreadyExists+": "+strings.Join(ato.Existing, ", "), "\n")) 440 | } 441 | 442 | _, _ = fmt.Fprintf(c.App.Writer, "%s\n", msg) 443 | 444 | return err 445 | } 446 | 447 | func processTagItems(c *cli.Context, opts configOptsOutput) (err error) { 448 | findTitle := c.String("find-title") 449 | findText := c.String("find-text") 450 | findTag := c.String("find-tag") 451 | newTags := c.String("title") 452 | 453 | sess, _, err := cache.GetSession(common.NewHTTPClient(), opts.useSession, opts.sessKey, opts.server, opts.debug) 454 | if err != nil { 455 | return err 456 | } 457 | 458 | if findText == "" && findTitle == "" && findTag == "" { 459 | fmt.Println("you must provide either text, title, or tag to search for") 460 | 461 | return cli.ShowSubcommandHelp(c) 462 | } 463 | 464 | processedTags := sncli.CommaSplit(newTags) 465 | 466 | sess.CacheDBPath, err = cache.GenCacheDBPath(sess, opts.cacheDBDir, snAppName) 467 | if err != nil { 468 | return err 469 | } 470 | 471 | appConfig := sncli.TagItemsConfig{ 472 | Session: &sess, 473 | FindText: findText, 474 | FindTitle: findTitle, 475 | FindTag: findTag, 476 | NewTags: processedTags, 477 | Replace: c.Bool("replace"), 478 | IgnoreCase: c.Bool("ignore-case"), 479 | Debug: opts.debug, 480 | } 481 | 482 | err = appConfig.Run() 483 | if err != nil { 484 | return err 485 | } 486 | 487 | return err 488 | } 489 | 490 | func processDeleteTags(c *cli.Context, opts configOptsOutput) (err error) { 491 | titleIn := strings.TrimSpace(c.String("title")) 492 | uuidIn := strings.ReplaceAll(c.String("uuid"), " ", "") 493 | 494 | if titleIn == "" && uuidIn == "" { 495 | _ = cli.ShowSubcommandHelp(c) 496 | 497 | return errors.New("title or uuid required") 498 | } 499 | 500 | var sess cache.Session 501 | 502 | sess, _, err = cache.GetSession(common.NewHTTPClient(), opts.useSession, opts.sessKey, opts.server, opts.debug) 503 | if err != nil { 504 | return err 505 | } 506 | 507 | tags := sncli.CommaSplit(titleIn) 508 | uuids := sncli.CommaSplit(uuidIn) 509 | 510 | var cacheDBPath string 511 | 512 | cacheDBPath, err = cache.GenCacheDBPath(sess, opts.cacheDBDir, snAppName) 513 | if err != nil { 514 | return err 515 | } 516 | 517 | sess.CacheDBPath = cacheDBPath 518 | 519 | DeleteTagConfig := sncli.DeleteTagConfig{ 520 | Session: &sess, 521 | TagTitles: tags, 522 | TagUUIDs: uuids, 523 | Debug: opts.debug, 524 | } 525 | 526 | var noDeleted int 527 | 528 | noDeleted, err = DeleteTagConfig.Run() 529 | if err != nil { 530 | return fmt.Errorf("%s: %s - %+v", msgFailedToDeleteTag, titleIn, err) 531 | } 532 | 533 | if noDeleted > 0 { 534 | _, _ = fmt.Fprint(c.App.Writer, color.Green.Sprintf("%s: %s", msgTagDeleted, titleIn)) 535 | 536 | return nil 537 | } 538 | 539 | _, _ = fmt.Fprint(c.App.Writer, color.Yellow.Sprintf("%s: %s", msgTagNotFound, titleIn)) 540 | 541 | return nil 542 | } 543 | 544 | func cmdTag() *cli.Command { 545 | return &cli.Command{ 546 | Name: "tag", 547 | Usage: "tag items", 548 | Flags: []cli.Flag{ 549 | &cli.StringFlag{ 550 | Name: "find-title", 551 | Usage: "match title", 552 | }, 553 | &cli.StringFlag{ 554 | Name: "find-text", 555 | Usage: "match text", 556 | }, 557 | &cli.StringFlag{ 558 | Name: "find-tag", 559 | Usage: "match tag", 560 | }, 561 | &cli.StringFlag{ 562 | Name: "title", 563 | Usage: "tag title to apply (separate multiple with commas)", 564 | }, 565 | &cli.BoolFlag{ 566 | Name: "purge", 567 | Usage: "delete other existing tags", 568 | }, 569 | &cli.BoolFlag{ 570 | Name: "ignore-case", 571 | Usage: "ignore case when matching", 572 | }, 573 | }, 574 | BashComplete: func(c *cli.Context) { 575 | if c.NArg() > 0 { 576 | return 577 | } 578 | for _, t := range []string{ 579 | "--find-title", "--find-text", "--find-tag", "--title", "--purge", "--ignore-case", 580 | } { 581 | fmt.Println(t) 582 | } 583 | }, 584 | Action: func(c *cli.Context) error { 585 | opts := getOpts(c) 586 | 587 | if err := processTagItems(c, opts); err != nil { 588 | return err 589 | } 590 | 591 | _, _ = fmt.Fprint(c.App.Writer, color.Green.Sprint(msgTagSuccess, "\n")) 592 | 593 | return nil 594 | }, 595 | } 596 | } 597 | -------------------------------------------------------------------------------- /cmd/sncli/tag_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestGetMissingTag(t *testing.T) { 13 | time.Sleep(250 * time.Millisecond) 14 | var outputBuffer bytes.Buffer 15 | app := appSetup() 16 | app.Writer = &outputBuffer 17 | 18 | require.NoError(t, app.Run([]string{"sncli", "get", "tag", "--title", "missing tag"})) 19 | stdout := outputBuffer.String() 20 | fmt.Println(stdout) 21 | require.Contains(t, stdout, msgNoMatches) 22 | } 23 | 24 | func TestAddTag(t *testing.T) { 25 | time.Sleep(250 * time.Millisecond) 26 | var outputBuffer bytes.Buffer 27 | app := appSetup() 28 | app.Writer = &outputBuffer 29 | 30 | require.NoError(t, app.Run([]string{"sncli", "add", "tag", "--title", "testAddOneTagGetCount"})) 31 | stdout := outputBuffer.String() 32 | fmt.Println(stdout) 33 | require.Contains(t, stdout, msgAddSuccess) 34 | 35 | outputBuffer.Reset() 36 | 37 | require.NoError(t, app.Run([]string{"sncli", "add", "tag", "--title", "testAddOneTagGetCount"})) 38 | stdout = outputBuffer.String() 39 | fmt.Println(stdout) 40 | require.Contains(t, stdout, msgTagAlreadyExists) 41 | 42 | // err := startCLI([]string{"sncli", "--debug", "--no-stdout", "wipe", "--yes"}) 43 | 44 | // cmd := cmdAdd() 45 | // cmd. 46 | // require.NoError(t, err) 47 | // err = startCLI([]string{"sncli", "--debug", "--no-stdout", "add", "tag", "--title", "testAddOneTagGetCount"}) 48 | // require.NoError(t, err) 49 | // require.Contains(t, msg, msgAddSuccess) 50 | } 51 | 52 | func TestAddGetTag(t *testing.T) { 53 | time.Sleep(250 * time.Millisecond) 54 | var outputBuffer bytes.Buffer 55 | app := appSetup() 56 | app.Writer = &outputBuffer 57 | 58 | // wipe 59 | osArgs := []string{"sncli", "wipe", "--yes"} 60 | err := app.Run(osArgs) 61 | stdout := outputBuffer.String() 62 | fmt.Println(stdout) 63 | require.NoError(t, err) 64 | 65 | // add tag 66 | osArgs = []string{"sncli", "add", "tag", "--title", "testAddOneTagGetCount"} 67 | err = app.Run(osArgs) 68 | stdout = outputBuffer.String() 69 | fmt.Println(stdout) 70 | require.NoError(t, err) 71 | require.Contains(t, stdout, msgAddSuccess) 72 | 73 | // get tag 74 | osArgs = []string{"sncli", "get", "tag", "--title", "testAddOneTagGetCount"} 75 | err = app.Run(osArgs) 76 | stdout = outputBuffer.String() 77 | fmt.Println(stdout) 78 | require.NoError(t, err) 79 | require.Contains(t, stdout, msgAddSuccess) 80 | } 81 | 82 | // func TestAddGetTagExport(t *testing.T) { 83 | // msg, _, err := startCLI([]string{"sncli", "--debug", "--no-stdout", "wipe", "--yes"}) 84 | // require.NoError(t, err) 85 | // msg, _, err = startCLI([]string{"sncli", "--debug", "--no-stdout", "add", "tag", "--title", "testAddOneTagGetCount"}) 86 | // require.NoError(t, err) 87 | // require.Contains(t, msg, msgAddSuccess) 88 | // msg, _, err = startCLI([]string{"sncli", "--debug", "--no-stdout", "get", "tag", "--title", "testAddOneTagGetCount"}) 89 | // require.NoError(t, err) 90 | // msg, _, err = startCLI([]string{"sncli", "--debug", "--no-stdout", "export"}) 91 | // require.NoError(t, err) 92 | // } 93 | 94 | func TestAddDeleteTag(t *testing.T) { 95 | time.Sleep(250 * time.Millisecond) 96 | var outputBuffer bytes.Buffer 97 | app := appSetup() 98 | app.Writer = &outputBuffer 99 | 100 | // wipe 101 | osArgs := []string{"sncli", "wipe", "--yes"} 102 | err := app.Run(osArgs) 103 | stdout := outputBuffer.String() 104 | fmt.Println(stdout) 105 | require.NoError(t, err) 106 | 107 | // add tag 108 | osArgs = []string{"sncli", "add", "tag", "--title", "testTag"} 109 | err = app.Run(osArgs) 110 | stdout = outputBuffer.String() 111 | fmt.Println(stdout) 112 | require.NoError(t, err) 113 | require.Contains(t, stdout, msgAddSuccess) 114 | 115 | // get tag 116 | osArgs = []string{"sncli", "get", "tag", "--title", "testTag"} 117 | err = app.Run(osArgs) 118 | stdout = outputBuffer.String() 119 | fmt.Println(stdout) 120 | require.NoError(t, err) 121 | require.Contains(t, stdout, msgAddSuccess) 122 | 123 | // delete tag 124 | osArgs = []string{"sncli", "delete", "tag", "--title", "testTag"} 125 | err = app.Run(osArgs) 126 | stdout = outputBuffer.String() 127 | fmt.Println(stdout) 128 | require.NoError(t, err) 129 | require.Contains(t, stdout, msgTagDeleted) 130 | } 131 | 132 | // func TestAddTagExportDeleteTagReImport(t *testing.T) { 133 | // msg, _, err := startCLI([]string{"sncli", "--debug", "--no-stdout", "wipe", "--yes"}) 134 | // require.NoError(t, err) 135 | // msg, _, err = startCLI([]string{"sncli", "--debug", "--no-stdout", "add", "tag", "--title", "testAddOneTagGetCount"}) 136 | // require.NoError(t, err) 137 | // require.Contains(t, msg, msgAddSuccess) 138 | // msg, _, err = startCLI([]string{"sncli", "--debug", "get", "tag", "--count"}) 139 | // require.NoError(t, err) 140 | // require.Equal(t, "1", msg) 141 | // msg, _, err = startCLI([]string{"sncli", "--debug", "--no-stdout", "export"}) 142 | // require.NoError(t, err) 143 | // require.True(t, strings.HasPrefix(msg, "encrypted export written to:")) 144 | // path := strings.TrimPrefix(msg, "encrypted export written to:") 145 | // msg, _, err = startCLI([]string{"sncli", "--debug", "--no-stdout", "delete", "tag", "--title", "testAddOneTagGetCount"}) 146 | // require.NoError(t, err) 147 | // require.Contains(t, msg, msgDeleted) 148 | // msg, _, err = startCLI([]string{"sncli", "--debug", "get", "tag", "--count"}) 149 | // require.NoError(t, err) 150 | // require.Equal(t, "0", msg) 151 | // msg, _, err = startCLI([]string{"sncli", "--debug", "import", "--experiment", "--file", path}) 152 | // require.NoError(t, err) 153 | // require.True(t, strings.HasPrefix(msg, "imported")) 154 | // msg, _, err = startCLI([]string{"sncli", "--debug", "get", "tag", "--count"}) 155 | // require.NoError(t, err) 156 | // require.Equal(t, "1", msg) 157 | // } 158 | 159 | func TestAddTagErrorMissingTitle(t *testing.T) { 160 | err := startCLI([]string{"sncli", "add", "tag"}) 161 | require.Error(t, err, "error should be returned if title is unspecified") 162 | } 163 | 164 | func TestDeleteTagErrorMissingTitle(t *testing.T) { 165 | err := startCLI([]string{"sncli", "delete", "tag"}) 166 | require.Error(t, err, "error should be returned if title is unspecified") 167 | } 168 | -------------------------------------------------------------------------------- /cmd/sncli/task.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | 7 | "github.com/jonhadfield/gosn-v2/cache" 8 | "github.com/jonhadfield/gosn-v2/common" 9 | sncli "github.com/jonhadfield/sn-cli" 10 | "github.com/spf13/viper" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | const ( 15 | txtDefaultGroup = "default_group" 16 | txtDefaultList = "default_list" 17 | msgTaskAdded = "task added" 18 | msgTaskCompleted = "task completed" 19 | msgTaskReopened = "task reopened" 20 | msgTaskDeleted = "task deleted" 21 | msgGroupAdded = "group added" 22 | msgGroupDeleted = "group deleted" 23 | txtOrdering = "ordering" 24 | txtOrderingLastUpdated = "last-updated" 25 | txtOrderingStandard = "standard" 26 | txtCompleted = "completed" 27 | flagTasklistName = "list" 28 | flagTitleName = "title" 29 | flagTaskName = "task" 30 | flagGroupName = "group" 31 | flagUUIDName = "uuid" 32 | defaultShowCompleted = false 33 | ) 34 | 35 | func cmdTask() *cli.Command { 36 | return &cli.Command{ 37 | Name: "task", 38 | Usage: "manage checklist tasks", 39 | BashComplete: func(c *cli.Context) { 40 | addTasks := []string{"add", "list", "show", "complete", "reopen", "delete"} 41 | if c.NArg() > 0 { 42 | return 43 | } 44 | for _, t := range addTasks { 45 | fmt.Println(t) 46 | } 47 | }, 48 | Subcommands: []*cli.Command{ 49 | cmdTaskAddTask(), 50 | cmdTaskComplete(), 51 | cmdTaskDelete(), 52 | cmdTaskList(), 53 | cmdTaskReopen(), 54 | cmdTaskShow(), 55 | }, 56 | } 57 | } 58 | 59 | func cmdTaskList() *cli.Command { 60 | return &cli.Command{ 61 | Name: "list", 62 | Usage: "list", 63 | Subcommands: nil, 64 | Action: func(c *cli.Context) error { 65 | opts := getOpts(c) 66 | 67 | sess, _, err := cache.GetSession(common.NewHTTPClient(), opts.useSession, opts.sessKey, opts.server, opts.debug) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | sess.CacheDBPath, err = cache.GenCacheDBPath(sess, opts.cacheDBDir, snAppName) 73 | if err != nil { 74 | return err 75 | } 76 | listTasklistsConfig := sncli.ListTasklistsInput{ 77 | Session: &sess, 78 | Debug: c.Bool("debug"), 79 | } 80 | 81 | if err = listTasklistsConfig.Run(); err != nil { 82 | return err 83 | } 84 | 85 | return nil 86 | }, 87 | } 88 | } 89 | 90 | func cmdTaskAddTask() *cli.Command { 91 | return &cli.Command{ 92 | Name: "add", 93 | Usage: "create a new task", 94 | Flags: []cli.Flag{ 95 | &cli.StringFlag{Name: flagTasklistName, Aliases: []string{"l"}, Value: viper.GetString(txtDefaultList)}, 96 | &cli.StringFlag{Name: flagGroupName, Aliases: []string{"g"}, Value: viper.GetString(txtDefaultGroup)}, 97 | &cli.StringFlag{Name: flagTitleName, Required: true}, 98 | }, 99 | BashComplete: func(c *cli.Context) { 100 | addTasks := []string{"--title", "--list", "--group"} 101 | if c.NArg() > 0 { 102 | return 103 | } 104 | for _, t := range addTasks { 105 | fmt.Println(t) 106 | } 107 | }, 108 | Action: func(c *cli.Context) error { 109 | if c.String(flagTasklistName) == "" && c.String(flagUUIDName) == "" { 110 | return fmt.Errorf("either --%s or --%s must be specified", flagTasklistName, flagUUIDName) 111 | } 112 | 113 | opts := getOpts(c) 114 | 115 | var sess cache.Session 116 | sess, _, err := cache.GetSession(common.NewHTTPClient(), opts.useSession, opts.sessKey, opts.server, opts.debug) 117 | if err != nil { 118 | return err 119 | } 120 | 121 | sess.CacheDBPath, err = cache.GenCacheDBPath(sess, opts.cacheDBDir, snAppName) 122 | if err != nil { 123 | return err 124 | } 125 | 126 | if c.String(flagGroupName) != "" { 127 | addTaskInput := sncli.AddAdvancedChecklistTaskInput{ 128 | Session: &sess, 129 | Tasklist: c.String(flagTasklistName), 130 | Group: c.String(flagGroupName), 131 | Title: c.String(flagTitleName), 132 | } 133 | 134 | if err = addTaskInput.Run(); err != nil { 135 | return err 136 | } 137 | } else { 138 | addTaskInput := sncli.AddTaskInput{ 139 | Session: &sess, 140 | Tasklist: c.String(flagTasklistName), 141 | Title: c.String(flagTitleName), 142 | UUID: c.String(flagUUIDName), 143 | } 144 | if err = addTaskInput.Run(); err != nil { 145 | return err 146 | } 147 | } 148 | 149 | fmt.Println(msgTaskAdded) 150 | 151 | return nil 152 | }, 153 | } 154 | } 155 | 156 | func cmdTaskShow() *cli.Command { 157 | viper.SetDefault("show_completed", defaultShowCompleted) 158 | 159 | return &cli.Command{ 160 | Name: "show", 161 | Usage: "show list", 162 | Flags: []cli.Flag{ 163 | &cli.StringFlag{Name: flagTasklistName, Aliases: []string{"l"}, Value: viper.GetString(txtDefaultList)}, 164 | &cli.StringFlag{Name: flagUUIDName}, 165 | &cli.BoolFlag{Name: txtCompleted, Aliases: []string{"c"}, Value: viper.GetBool("show_completed"), Usage: "show completed tasks"}, 166 | &cli.StringFlag{ 167 | Name: txtOrdering, 168 | Aliases: []string{"order"}, 169 | Hidden: true, 170 | Usage: "order by standard (as shown in the app) or last-updated", 171 | Value: txtOrderingStandard, 172 | }, 173 | }, 174 | 175 | Action: func(c *cli.Context) error { 176 | var err error 177 | 178 | ordering := c.String(txtOrdering) 179 | if !slices.Contains([]string{txtOrderingStandard, txtOrderingLastUpdated}, ordering) { 180 | return fmt.Errorf("%s must be either standard or last-updated", txtOrdering) 181 | } 182 | 183 | opts := getOpts(c) 184 | 185 | var sess cache.Session 186 | sess, _, err = cache.GetSession(common.NewHTTPClient(), viper.GetBool("use_session"), opts.sessKey, viper.GetString("server"), opts.debug) 187 | if err != nil { 188 | return err 189 | } 190 | 191 | sess.CacheDBPath, err = cache.GenCacheDBPath(sess, opts.cacheDBDir, snAppName) 192 | if err != nil { 193 | return err 194 | } 195 | 196 | showTasklistInput := sncli.ShowTasklistInput{ 197 | Session: &sess, 198 | Title: c.String(flagTasklistName), 199 | UUID: c.String(flagUUIDName), 200 | ShowCompleted: c.Bool(txtCompleted), 201 | Ordering: ordering, 202 | } 203 | 204 | return showTasklistInput.Run() 205 | }, 206 | } 207 | } 208 | 209 | func cmdTaskDelete() *cli.Command { 210 | return &cli.Command{ 211 | Name: "delete-task", 212 | Usage: "delete task", 213 | Aliases: []string{ 214 | "delete", 215 | }, 216 | Flags: []cli.Flag{ 217 | &cli.StringFlag{Name: flagTasklistName, Aliases: []string{"l"}, Value: viper.GetString(txtDefaultList)}, 218 | &cli.StringFlag{Name: flagGroupName, Aliases: []string{"g"}, Value: viper.GetString(txtDefaultGroup)}, 219 | &cli.StringFlag{Name: flagTitleName, Aliases: []string{flagTaskName}}, 220 | }, 221 | BashComplete: func(c *cli.Context) { 222 | addTasks := []string{"--title", "--list", "--group"} 223 | if c.NArg() > 0 { 224 | return 225 | } 226 | for _, t := range addTasks { 227 | fmt.Println(t) 228 | } 229 | }, 230 | Action: func(c *cli.Context) error { 231 | opts := getOpts(c) 232 | 233 | sess, _, err := cache.GetSession(common.NewHTTPClient(), opts.useSession, opts.sessKey, opts.server, opts.debug) 234 | if err != nil { 235 | return err 236 | } 237 | 238 | sess.CacheDBPath, err = cache.GenCacheDBPath(sess, opts.cacheDBDir, snAppName) 239 | if err != nil { 240 | return err 241 | } 242 | 243 | group := c.String(flagGroupName) 244 | if group != "" { 245 | deleteTaskInput := sncli.DeleteAdvancedChecklistTaskInput{ 246 | Session: &sess, 247 | Debug: c.Bool("debug"), 248 | Title: c.String(flagTitleName), 249 | Group: c.String(flagGroupName), 250 | Checklist: c.String(flagTasklistName), 251 | } 252 | err = deleteTaskInput.Run() 253 | if err != nil { 254 | return err 255 | } 256 | } else { 257 | deleteTaskInput := sncli.DeleteTaskInput{ 258 | Session: &sess, 259 | Debug: c.Bool("debug"), 260 | Tasklist: c.String(flagTasklistName), 261 | Title: c.String(flagTitleName), 262 | } 263 | 264 | if err = deleteTaskInput.Run(); err != nil { 265 | return err 266 | } 267 | } 268 | 269 | fmt.Println(msgTaskDeleted) 270 | 271 | return nil 272 | }, 273 | } 274 | } 275 | 276 | func cmdTaskComplete() *cli.Command { 277 | return &cli.Command{ 278 | Name: "complete", 279 | Usage: "complete task", 280 | Aliases: []string{ 281 | "close", 282 | }, 283 | Flags: []cli.Flag{ 284 | &cli.StringFlag{Name: flagTasklistName, Aliases: []string{"l"}, Value: viper.GetString(txtDefaultList)}, 285 | &cli.StringFlag{Name: flagGroupName, Aliases: []string{"g"}, Value: viper.GetString(txtDefaultGroup)}, 286 | &cli.StringFlag{Name: flagTitleName}, 287 | &cli.StringFlag{Name: flagUUIDName}, 288 | }, 289 | BashComplete: func(c *cli.Context) { 290 | if c.NArg() > 0 { 291 | return 292 | } 293 | for _, t := range []string{"--title", "--list", "--group", "--uuid"} { 294 | fmt.Println(t) 295 | } 296 | }, 297 | Action: func(c *cli.Context) error { 298 | if c.String(flagTasklistName) == "" && c.String(flagUUIDName) == "" { 299 | return fmt.Errorf("either --%s or --%s must be specified", flagTasklistName, flagUUIDName) 300 | } 301 | 302 | opts := getOpts(c) 303 | 304 | sess, _, err := cache.GetSession(common.NewHTTPClient(), opts.useSession, opts.sessKey, opts.server, opts.debug) 305 | if err != nil { 306 | return err 307 | } 308 | 309 | sess.CacheDBPath, err = cache.GenCacheDBPath(sess, opts.cacheDBDir, snAppName) 310 | if err != nil { 311 | return err 312 | } 313 | 314 | if c.String(flagGroupName) != "" { 315 | completeAdvancedTaskInput := sncli.CompleteAdvancedTaskInput{ 316 | Session: &sess, 317 | Tasklist: c.String(flagTasklistName), 318 | Group: c.String(flagGroupName), 319 | Title: c.String(flagTitleName), 320 | } 321 | 322 | if err = completeAdvancedTaskInput.Run(); err != nil { 323 | return err 324 | } 325 | } else { 326 | completeTaskInput := sncli.CompleteTaskInput{ 327 | Session: &sess, 328 | Tasklist: c.String(flagTasklistName), 329 | Title: c.String(flagTitleName), 330 | } 331 | 332 | if err = completeTaskInput.Run(); err != nil { 333 | return err 334 | } 335 | } 336 | 337 | fmt.Println(msgTaskCompleted) 338 | 339 | return nil 340 | }, 341 | } 342 | } 343 | 344 | func cmdTaskReopen() *cli.Command { 345 | return &cli.Command{ 346 | Name: "reopen", 347 | Usage: "reopen task", 348 | Flags: []cli.Flag{ 349 | &cli.StringFlag{Name: flagTasklistName, Aliases: []string{"l"}, Value: viper.GetString(txtDefaultList)}, 350 | &cli.StringFlag{Name: flagGroupName, Value: viper.GetString(txtDefaultGroup)}, 351 | &cli.StringFlag{Name: flagTitleName, Required: true}, 352 | &cli.StringFlag{Name: flagUUIDName}, 353 | }, 354 | 355 | Action: func(c *cli.Context) error { 356 | if c.String(flagTasklistName) == "" && c.String(flagUUIDName) == "" { 357 | return fmt.Errorf("either --%s or --%s must be specified", flagTasklistName, flagUUIDName) 358 | } 359 | 360 | opts := getOpts(c) 361 | sess, _, err := cache.GetSession(common.NewHTTPClient(), opts.useSession, opts.sessKey, opts.server, opts.debug) 362 | if err != nil { 363 | return err 364 | } 365 | 366 | sess.CacheDBPath, err = cache.GenCacheDBPath(sess, opts.cacheDBDir, snAppName) 367 | if err != nil { 368 | return err 369 | } 370 | 371 | if c.String(flagGroupName) != "" { 372 | reopenTaskInput := sncli.ReopenAdvancedTaskInput{ 373 | Session: &sess, 374 | Tasklist: c.String(flagTasklistName), 375 | Group: c.String(flagGroupName), 376 | Title: c.String(flagTitleName), 377 | UUID: c.String(flagUUIDName), 378 | } 379 | 380 | if err = reopenTaskInput.Run(); err != nil { 381 | return err 382 | } 383 | } else { 384 | reopenTaskInput := sncli.ReopenTaskInput{ 385 | Session: &sess, 386 | Tasklist: c.String(flagTasklistName), 387 | Title: c.String(flagTitleName), 388 | UUID: c.String(flagUUIDName), 389 | } 390 | 391 | if err = reopenTaskInput.Run(); err != nil { 392 | return err 393 | } 394 | } 395 | 396 | fmt.Println(msgTaskReopened) 397 | 398 | return nil 399 | }, 400 | } 401 | } 402 | -------------------------------------------------------------------------------- /cmd/sncli/wipe.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jonhadfield/gosn-v2/cache" 7 | "github.com/jonhadfield/gosn-v2/common" 8 | sncli "github.com/jonhadfield/sn-cli" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func cmdWipe() *cli.Command { 13 | return &cli.Command{ 14 | Name: "wipe", 15 | Usage: "deletes all supported content", 16 | Flags: []cli.Flag{ 17 | &cli.BoolFlag{ 18 | Name: "yes", 19 | Usage: "accpet warning", 20 | }, 21 | &cli.BoolFlag{ 22 | Name: "everything", 23 | Usage: "wipe settings also", 24 | }, 25 | }, 26 | BashComplete: func(c *cli.Context) { 27 | if c.NArg() > 0 { 28 | return 29 | } 30 | 31 | for _, t := range []string{"--yes", "--everything"} { 32 | fmt.Println(t) 33 | } 34 | }, 35 | Action: func(c *cli.Context) error { 36 | opts := getOpts(c) 37 | 38 | cacheSession, _, err := cache.GetSession(common.NewHTTPClient(), opts.useSession, opts.sessKey, opts.server, opts.debug) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | var cacheDBPath string 44 | 45 | cacheDBPath, err = cache.GenCacheDBPath(cacheSession, opts.cacheDBDir, snAppName) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | cacheSession.CacheDBPath = cacheDBPath 51 | wipeConfig := sncli.WipeConfig{ 52 | Session: &cacheSession, 53 | UseStdOut: opts.useStdOut, 54 | Everything: c.Bool("everything"), 55 | Debug: opts.debug, 56 | } 57 | 58 | var proceed bool 59 | if c.Bool("yes") { 60 | proceed = true 61 | } else { 62 | fmt.Printf("wipe all items for account %s? ", cacheSession.Session.KeyParams.Identifier) 63 | var input string 64 | _, err = fmt.Scanln(&input) 65 | if err == nil && sncli.StringInSlice(input, []string{"y", "yes"}, false) { 66 | proceed = true 67 | } 68 | } 69 | if proceed { 70 | var numWiped int 71 | var wipeErr error 72 | numWiped, wipeErr = wipeConfig.Run() 73 | if wipeErr != nil { 74 | return wipeErr 75 | } 76 | 77 | _, _ = fmt.Fprintf(c.App.Writer, "%d %s", numWiped, msgItemsDeleted) 78 | 79 | return nil 80 | } 81 | 82 | return nil 83 | }, 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /cmd/sncli/wipe_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestWipe(t *testing.T) { 12 | time.Sleep(250 * time.Millisecond) 13 | var outputBuffer bytes.Buffer 14 | app := appSetup() 15 | app.Writer = &outputBuffer 16 | 17 | osArgs := []string{"sncli", "wipe", "--yes"} 18 | err := app.Run(osArgs) 19 | stdout := outputBuffer.String() 20 | // fmt.Println(stdout) 21 | require.NoError(t, err) 22 | require.Contains(t, stdout, msgItemsDeleted) 23 | } 24 | 25 | // func TestAddDeleteNote(t *testing.T) { 26 | // err := startCLI([]string{"sncli", "--debug", "--no-stdout", "wipe", "--yes"}) 27 | // require.NoError(t, err, "failed to wipe") 28 | // err := startCLI([]string{"sncli", "--debug", "--no-stdout", "add", "note", "--title", "testNote", "--text", "some example text"}) 29 | // require.NoError(t, err, "failed to add note") 30 | // require.Contains(t, msg, msgAddSuccess) 31 | // err = startCLI([]string{"sncli", "--debug", "--no-stdout", "get", "note", "--count"}) 32 | // require.NoError(t, err, "failed to get note count") 33 | // require.Equal(t, "1", msg) 34 | // err = startCLI([]string{"sncli", "--debug", "--no-stdout", "delete", "note", "--title", "testNote"}) 35 | // require.NoError(t, err, "failed to delete note") 36 | // require.Contains(t, msg, msgDeleted) 37 | // err = startCLI([]string{"sncli", "--debug", "--no-stdout", "get", "note", "--count"}) 38 | // require.NoError(t, err, "failed to get note count") 39 | // require.Equal(t, "0", msg) 40 | // time.Sleep(250 * time.Millisecond) 41 | // } 42 | // 43 | // func TestAddNoteErrorMissingTitle(t *testing.T) { 44 | // err := startCLI([]string{"sncli", "--debug", "--no-stdout", "add", "note"}) 45 | // require.Error(t, err) 46 | // } 47 | // 48 | // func TestDeleteNoteErrorMissingTitle(t *testing.T) { 49 | // err := startCLI([]string{"sncli", "--debug", "--no-stdout", "delete", "note"}) 50 | // require.Error(t, err, "error should be returned if title is unspecified") 51 | // } 52 | // 53 | // func TestTagNotesByTextWithNewTags(t *testing.T) { 54 | // err := startCLI([]string{"sncli", "--debug", "--no-stdout", "add", "note", "--title", "TestNoteOne", "--text", "test note one"}) 55 | // require.Contains(t, msg, msgAddSuccess) 56 | // require.NoError(t, err) 57 | // 58 | // err = startCLI([]string{"sncli", "--debug", "--no-stdout", "add", "note", "--title", "TestNoteTwo", "--text", "test note two"}) 59 | // require.NoError(t, err) 60 | // require.Contains(t, msg, msgAddSuccess) 61 | // 62 | // err = startCLI([]string{"sncli", "--debug", "--no-stdout", "tag", "--find-text", "test note", "--title", "testTagOne,testTagTwo"}) 63 | // require.NoError(t, err) 64 | // 65 | // err = startCLI([]string{"sncli", "--debug", "--no-stdout", "delete", "note", "--title", "TestNoteOne,TestNoteTwo"}) 66 | // require.NoError(t, err) 67 | // 68 | // err = startCLI([]string{"sncli", "--debug", "--no-stdout", "get", "note"}) 69 | // require.NoError(t, err) 70 | // 71 | // err = startCLI([]string{"sncli", "--debug", "--no-stdout", "get", "note", "--count"}) 72 | // require.NoError(t, err) 73 | // require.Equal(t, "0", msg) 74 | // err = startCLI([]string{"sncli", "--debug", "--no-stdout", "get", "note"}) 75 | // require.NoError(t, err) 76 | // require.NotEmpty(t, msg) 77 | // 78 | // err = startCLI([]string{"sncli", "--debug", "--no-stdout", "delete", "tag", "--title", "testTagOne,testTagTwo"}) 79 | // require.NoError(t, err) 80 | // time.Sleep(250 * time.Millisecond) 81 | // } 82 | // 83 | // func TestAddOneNoteGetCount(t *testing.T) { 84 | // err := startCLI([]string{ 85 | // "sncli", "--debug", "--no-stdout", "add", "note", "--title", "testAddOneNoteGetCount Title", 86 | // "--text", "testAddOneNoteGetCount Text", 87 | // }) 88 | // require.NoError(t, err) 89 | // require.Contains(t, msg, msgAddSuccess) 90 | // 91 | // err = startCLI([]string{"sncli", "--debug", "--no-stdout", "get", "note"}) 92 | // require.NoError(t, err) 93 | // 94 | // err = startCLI([]string{"sncli", "--debug", "--no-stdout", "get", "note", "--count"}) 95 | // require.NoError(t, err) 96 | // require.Equal(t, "1", msg) 97 | // 98 | // err = startCLI([]string{"sncli", "--debug", "--no-stdout", "delete", "note", "--title", "testAddOneNoteGetCount Title"}) 99 | // require.NoError(t, err) 100 | // time.Sleep(250 * time.Millisecond) 101 | // } 102 | // 103 | // func TestAddOneTagGetCount(t *testing.T) { 104 | // err := startCLI([]string{"sncli", "--debug", "add", "tag", "--title", "testAddOneTagGetCount Title"}) 105 | // require.NoError(t, err) 106 | // require.Contains(t, msg, msgAddSuccess) 107 | // err = startCLI([]string{"sncli", "--debug", "get", "tag", "--count"}) 108 | // require.NoError(t, err) 109 | // require.Equal(t, "1", msg) 110 | // 111 | // err = startCLI([]string{"sncli", "--debug", "--no-stdout", "delete", "tag", "--title", "testAddOneTagGetCount Title"}) 112 | // require.NoError(t, err) 113 | // 114 | // time.Sleep(250 * time.Millisecond) 115 | // } 116 | // 117 | // func TestGetNoteCountWithNoResults(t *testing.T) { 118 | // err := startCLI([]string{"sncli", "--debug", "--no-stdout", "get", "note", "--count"}) 119 | // require.NoError(t, err) 120 | // require.Equal(t, "0", msg) 121 | // time.Sleep(250 * time.Millisecond) 122 | // } 123 | // 124 | // func TestGetTagCountWithNoResults(t *testing.T) { 125 | // err := startCLI([]string{"sncli", "--debug", "get", "tag", "--count"}) 126 | // require.NoError(t, err) 127 | // require.Equal(t, "0", msg) 128 | // time.Sleep(250 * time.Millisecond) 129 | // } 130 | // 131 | // func TestGetNotesWithNoResults(t *testing.T) { 132 | // err := startCLI([]string{"sncli", "--debug", "get", "note"}) 133 | // require.NoError(t, err) 134 | // require.Equal(t, msgNoMatches, msg) 135 | // time.Sleep(250 * time.Millisecond) 136 | // } 137 | // 138 | // func TestGetTagsWithNoResults(t *testing.T) { 139 | // err := startCLI([]string{"sncli", "--debug", "get", "tag"}) 140 | // require.NoError(t, err) 141 | // require.Equal(t, msgNoMatches, msg) 142 | // time.Sleep(250 * time.Millisecond) 143 | // } 144 | // 145 | // func TestFinalWipeAndCountZero(t *testing.T) { 146 | // err := startCLI([]string{"sncli", "--debug", "wipe", "--yes"}) 147 | // require.NoError(t, err) 148 | // 149 | // var msg string 150 | // 151 | // err = startCLI([]string{"sncli", "--debug", "get", "note", "--count"}) 152 | // require.NoError(t, err) 153 | // require.Equal(t, "0", msg) 154 | // 155 | // err = startCLI([]string{"sncli", "--debug", "get", "tag", "--count"}) 156 | // require.NoError(t, err) 157 | // require.Equal(t, "0", msg) 158 | // } 159 | -------------------------------------------------------------------------------- /constants.go: -------------------------------------------------------------------------------- 1 | package sncli 2 | 3 | import "time" 4 | 5 | const spinnerDelay = 100 * time.Millisecond 6 | -------------------------------------------------------------------------------- /debug.go: -------------------------------------------------------------------------------- 1 | package sncli 2 | 3 | import ( 4 | "encoding/base64" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/gookit/color" 10 | "github.com/jonhadfield/gosn-v2/crypto" 11 | "github.com/jonhadfield/gosn-v2/items" 12 | "github.com/jonhadfield/gosn-v2/session" 13 | ) 14 | 15 | type DecryptStringInput struct { 16 | Session session.Session 17 | In string 18 | UseStdOut bool 19 | Key string 20 | } 21 | 22 | func DecryptString(input DecryptStringInput) (string, error) { 23 | key1 := input.Session.MasterKey 24 | if input.Key != "" { 25 | key1 = input.Key 26 | } 27 | 28 | // trim noise 29 | if strings.HasPrefix(input.In, "enc_item_key") && len(input.In) > 13 { 30 | input.In = strings.TrimSpace(input.In)[13:] 31 | } 32 | if strings.HasPrefix(input.In, "content") && len(input.In) > 8 { 33 | input.In = strings.TrimSpace(input.In)[8:] 34 | } 35 | 36 | version, nonce, cipherText, authData := splitContent(input.In) 37 | if version != "004" { 38 | return "", errors.New("only version 004 of encryption is supported") 39 | } 40 | 41 | bad, err := base64.StdEncoding.DecodeString(authData) 42 | if err != nil { 43 | return "", fmt.Errorf("failed to base64 decode auth data: '%s' err: %+v", authData, err) 44 | } 45 | fmt.Printf("Decoded Auth Data: %+v\n", string(bad)) 46 | 47 | pb, err := crypto.DecryptCipherText(cipherText, key1, nonce, authData) 48 | if err != nil { 49 | return "", err 50 | } 51 | 52 | return string(pb), nil 53 | } 54 | 55 | type OutputSessionInput struct { 56 | Session session.Session 57 | In string 58 | UseStdOut bool 59 | OutputMasterKey bool 60 | } 61 | 62 | func OutputSession(input OutputSessionInput) error { 63 | fmt.Println(color.Bold.Sprintf("session")) 64 | fmt.Printf("debug: %t\n\n", input.Session.Debug) 65 | fmt.Println("key params") 66 | fmt.Printf("- identifier: %s\n", input.Session.KeyParams.Identifier) 67 | fmt.Printf("- nonce: %s\n", input.Session.KeyParams.PwNonce) 68 | fmt.Printf("- created: %s\n", input.Session.KeyParams.Created) 69 | fmt.Printf("- origination: %s\n", input.Session.KeyParams.Origination) 70 | fmt.Printf("- version: %s\n", input.Session.KeyParams.Version) 71 | fmt.Println() 72 | if input.OutputMasterKey { 73 | fmt.Printf("master key: %s\n", input.Session.MasterKey) 74 | fmt.Println() 75 | } 76 | 77 | _, err := items.Sync(items.SyncInput{Session: &input.Session}) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | // output default items key 83 | ik := input.Session.DefaultItemsKey 84 | fmt.Println("default items key") 85 | fmt.Printf("- uuid %s key %s created-at %d updated-at %d\n", ik.UUID, ik.ItemsKey, ik.CreatedAtTimestamp, ik.UpdatedAtTimestamp) 86 | 87 | // output all items keys in session 88 | fmt.Println("items keys") 89 | 90 | for _, ik = range input.Session.ItemsKeys { 91 | fmt.Printf("- uuid %s key %s created-at %d updated-at %d\n", ik.UUID, ik.ItemsKey, ik.CreatedAtTimestamp, ik.UpdatedAtTimestamp) 92 | } 93 | 94 | return nil 95 | } 96 | 97 | type CreateItemsKeyInput struct { 98 | Debug bool 99 | MasterKey string 100 | } 101 | 102 | // func CreateItemsKey(input CreateItemsKeyInput) error { 103 | // ik := items.NewItemsKey() 104 | // fmt.Printf("%+v\n", ik.ItemsKey) 105 | // 106 | // return nil 107 | // } 108 | 109 | func splitContent(in string) (string, string, string, string) { 110 | components := strings.Split(in, ":") 111 | if len(components) < 3 { 112 | panic(components) 113 | } 114 | 115 | version := components[0] // protocol version 116 | nonce := components[1] // encryption nonce 117 | cipherText := components[2] // ciphertext 118 | authenticatedData := components[3] // authenticated data 119 | 120 | return version, nonce, cipherText, authenticatedData 121 | } 122 | -------------------------------------------------------------------------------- /debug_test.go: -------------------------------------------------------------------------------- 1 | package sncli 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/jonhadfield/gosn-v2/session" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestDebugStringItemsKeyEncItemKey(t *testing.T) { 11 | plaintext, err := DecryptString(DecryptStringInput{ 12 | Session: session.Session{}, 13 | In: "004:966fb3ab4422f3b2a0ea20159d769cbee1d7f763d5aba425:xRmX5+LlNYk4bXI4OaeKNMI2LZE3QeLO5xFVwy+v3PY8hBUBOuDRa+n7m01gfA57fPL4JBrWhGJ9b8gaiOPFGC8ntlBL7+qhj/sQ/cbA4Po=:eyJrcCI6eyJjcmVhdGVkIjoiMTYwODQ3MzM4Nzc5OSIsImlkZW50aWZpZXIiOiJsZW1vbjIiLCJvcmlnaW5hdGlvbiI6InJlZ2lzdHJhdGlvbiIsInB3X25vbmNlIjoiNGZXc2RUOHJaY2NOTlR1aWVZcTF2bGJ6YkIzRmVkeE0iLCJ2ZXJzaW9uIjoiMDA0In0sInUiOiJhMWIxZDYwYy1mNjA1LTQ2MDQtOGE5ZS03NjE1NDkyODI4M2IiLCJ2IjoiMDA0In0=", 14 | UseStdOut: false, 15 | Key: "9dbd97421d3981c433979fc8d86559734331f711372c4ad7a0a6830fff75af68", 16 | }) 17 | require.NoError(t, err) 18 | require.Equal(t, "449adb29a39e770048dc6126565d2fe5c3a9b4094f19c1e109b84b17d0cf27bb", plaintext) 19 | } 20 | 21 | func TestDebugStringItemsKeyEncItemKeySession(t *testing.T) { 22 | plaintext, err := DecryptString(DecryptStringInput{ 23 | Session: session.Session{ 24 | MasterKey: "9dbd97421d3981c433979fc8d86559734331f711372c4ad7a0a6830fff75af68", 25 | }, 26 | In: "004:966fb3ab4422f3b2a0ea20159d769cbee1d7f763d5aba425:xRmX5+LlNYk4bXI4OaeKNMI2LZE3QeLO5xFVwy+v3PY8hBUBOuDRa+n7m01gfA57fPL4JBrWhGJ9b8gaiOPFGC8ntlBL7+qhj/sQ/cbA4Po=:eyJrcCI6eyJjcmVhdGVkIjoiMTYwODQ3MzM4Nzc5OSIsImlkZW50aWZpZXIiOiJsZW1vbjIiLCJvcmlnaW5hdGlvbiI6InJlZ2lzdHJhdGlvbiIsInB3X25vbmNlIjoiNGZXc2RUOHJaY2NOTlR1aWVZcTF2bGJ6YkIzRmVkeE0iLCJ2ZXJzaW9uIjoiMDA0In0sInUiOiJhMWIxZDYwYy1mNjA1LTQ2MDQtOGE5ZS03NjE1NDkyODI4M2IiLCJ2IjoiMDA0In0=", 27 | UseStdOut: false, 28 | }) 29 | require.NoError(t, err) 30 | require.Equal(t, "449adb29a39e770048dc6126565d2fe5c3a9b4094f19c1e109b84b17d0cf27bb", plaintext) 31 | } 32 | 33 | func TestDebugStringItemsKeyContent(t *testing.T) { 34 | plaintext, err := DecryptString(DecryptStringInput{ 35 | In: "004:0f62ec0954de2aaf4f6bf529e6478b6725774cd3ce396d94:+keNC3SOAPp890NTrHTKnDn8tK8QgCNVA51U1L3XWfOK4lr65Ju4qtciY57NTDrXKok80CeyzY6lPwtW8dIExgHDKf+yjlPYHqxLwWOXytDvZA9o/8kQ0ciYG9XLdN9YCuUw3evV7jXkB5cVa6kUwqLhbQnerCXrXOaiFPkUoxaAxP7GP8ciYdwegRkag67DiZEbD5d5/iPGY2zN4u4ltapgWMU7BgbTMpvJUaMzYyrolmw6eY9KVS3x02IKHhaYbtd6Co5/YG1BGEY85F3vfuSkusR3Pwci2pk1nOcTKyukUoRCksyubr9G963HG/BKmAxmq02txd+D6ppZjoIfIoxeG+JNSvXcMp0iXMk3wrzHIKaA6/6v8n4fZAv61p1JEni3MAKwtMk6/XetdA==:eyJrcCI6eyJjcmVhdGVkIjoiMTYwODQ3MzM4Nzc5OSIsImlkZW50aWZpZXIiOiJsZW1vbjIiLCJvcmlnaW5hdGlvbiI6InJlZ2lzdHJhdGlvbiIsInB3X25vbmNlIjoiNGZXc2RUOHJaY2NOTlR1aWVZcTF2bGJ6YkIzRmVkeE0iLCJ2ZXJzaW9uIjoiMDA0In0sInUiOiJhMWIxZDYwYy1mNjA1LTQ2MDQtOGE5ZS03NjE1NDkyODI4M2IiLCJ2IjoiMDA0In0=", 36 | UseStdOut: false, 37 | Key: "449adb29a39e770048dc6126565d2fe5c3a9b4094f19c1e109b84b17d0cf27bb", 38 | }) 39 | require.NoError(t, err) 40 | require.Equal(t, "{\"itemsKey\":\"b710239b882127f663f2be3e2811a4add4e465b23458c8a770facc1aaed5b526\",\"version\":\"004\",\"references\":[],\"appData\":{\"org.standardnotes.sn\":{\"client_updated_at\":\"Thu Feb 10 2022 19:58:58 GMT+0000 (Greenwich Mean Time)\",\"prefersPlainEditor\":false,\"pinned\":false}},\"isDefault\":true}", plaintext) 41 | } 42 | -------------------------------------------------------------------------------- /docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## changelog 2 | 0.2.7 - fix archive names, combine MacOS builds into universal binary 3 | 0.0.13 - enable storing of session in MacOS and Linux with optional encryption 4 | 0.0.12 - fix Register bug on Windows 5 | 0.0.11 - fix ReadPassword bug on Windows 6 | 0.0.10 - update dependencies and fix gosn issue 7 | 0.0.9 - minor fix 8 | 0.0.8 - add encrypted export and import feature 9 | 0.0.7 - add fixup option to resolve note and tag issues, retry logic for item puts 10 | 0.0.6 - fix count issue 11 | 0.0.5 - added option to save session (unencrypted for now) 12 | 0.0.4 - added Windows support 13 | 0.0.3 - added note content from file 14 | 0.0.2 - added bash completion 15 | 0.0.1 - initial -------------------------------------------------------------------------------- /export.go: -------------------------------------------------------------------------------- 1 | package sncli 2 | 3 | import ( 4 | "github.com/jonhadfield/gosn-v2/cache" 5 | ) 6 | 7 | type ExportConfig struct { 8 | Session *cache.Session 9 | Decrypted bool 10 | File string 11 | UseStdOut bool 12 | } 13 | 14 | type ImportConfig struct { 15 | Session *cache.Session 16 | File string 17 | Format string 18 | UseStdOut bool 19 | Debug bool 20 | } 21 | 22 | // 23 | // // Run will retrieve all items from SN directly, re-encrypt them with a new ItemsKey and write them to a file. 24 | // func (i ExportConfig) Run() error { 25 | // if !i.Session.Debug { 26 | // prefix := HiWhite("exporting ") 27 | // 28 | // s := spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithWriter(os.Stdout)) 29 | // if i.UseStdOut { 30 | // s = spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithWriter(os.Stderr)) 31 | // } 32 | // 33 | // s.Prefix = prefix 34 | // s.Start() 35 | // 36 | // err := i.Session.Export(i.File) 37 | // 38 | // s.Stop() 39 | // 40 | // return err 41 | // } 42 | // 43 | // return i.Session.Export(i.File) 44 | // } 45 | 46 | // func (i *ImportConfig) Run() (imported int, err error) { 47 | // // populate DB 48 | // gii := cache.SyncInput{ 49 | // Session: i.Session, 50 | // } 51 | // 52 | // gio, err := Sync(gii, true) 53 | // if err != nil { 54 | // return imported, err 55 | // } 56 | // 57 | // var syncTokens []cache.SyncToken 58 | // if err = gio.DB.All(&syncTokens); err != nil { 59 | // return imported, err 60 | // } 61 | // syncToken := "" 62 | // if len(syncTokens) > 0 { 63 | // syncToken = syncTokens[0].SyncToken 64 | // } 65 | // if err = gio.DB.Close(); err != nil { 66 | // return imported, err 67 | // } 68 | // 69 | // // request all items from SN 70 | // var iItemsKey items.ItemsKey 71 | // var iItems items.EncryptedItems 72 | // 73 | // iItems, iItemsKey, err = i.Session.Session.Import(i.File, syncToken, "") 74 | // if err != nil { 75 | // return 0, err 76 | // } 77 | // 78 | // if iItemsKey.ItemsKey == "" { 79 | // panic(fmt.Sprintf("iItemsKey.ItemsKey is empty for: '%s'", iItemsKey.UUID)) 80 | // } 81 | // 82 | // // push item and close db 83 | // pii := cache.SyncInput{ 84 | // Session: i.Session, 85 | // Close: true, 86 | // } 87 | // 88 | // _, err = Sync(pii, true) 89 | // imported = len(iItems) 90 | // 91 | // return imported, err 92 | // } 93 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jonhadfield/sn-cli 2 | 3 | go 1.25.1 4 | 5 | require ( 6 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 7 | github.com/danieljoos/wincred v1.2.2 // indirect 8 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 9 | github.com/matryer/try v0.0.0-20161228173917-9ac251b645a2 // indirect 10 | github.com/mattn/go-colorable v0.1.14 // indirect 11 | github.com/mattn/go-isatty v0.0.20 // indirect 12 | github.com/mitchellh/go-homedir v1.1.0 // indirect 13 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 14 | github.com/spf13/afero v1.15.0 // indirect 15 | github.com/spf13/cast v1.10.0 // indirect 16 | github.com/spf13/pflag v1.0.10 // indirect 17 | github.com/subosito/gotenv v1.6.0 // indirect 18 | github.com/zalando/go-keyring v0.2.6 // indirect 19 | go.etcd.io/bbolt v1.4.3 // indirect 20 | golang.org/x/sys v0.36.0 // indirect 21 | golang.org/x/term v0.35.0 22 | golang.org/x/text v0.29.0 // indirect 23 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 24 | gopkg.in/yaml.v3 v3.0.1 25 | ) 26 | 27 | require ( 28 | github.com/alexeyco/simpletable v1.0.0 29 | github.com/asdine/storm/v3 v3.2.1 30 | github.com/briandowns/spinner v1.23.2 31 | github.com/divan/num2words v1.0.3 32 | github.com/dustin/go-humanize v1.0.1 33 | github.com/gookit/color v1.6.0 34 | github.com/jonhadfield/gosn-v2 v0.0.0-20250927082113-33b7d8a30098 35 | github.com/pterm/pterm v0.12.81 36 | github.com/ryanuber/columnize v2.1.2+incompatible 37 | github.com/spf13/viper v1.21.0 38 | github.com/stretchr/testify v1.11.1 39 | github.com/urfave/cli/v2 v2.27.7 40 | gopkg.in/yaml.v2 v2.2.4 41 | ) 42 | 43 | require ( 44 | al.essio.dev/pkg/shellescape v1.6.0 // indirect 45 | atomicgo.dev/cursor v0.2.0 // indirect 46 | atomicgo.dev/keyboard v0.2.9 // indirect 47 | atomicgo.dev/schedule v0.1.0 // indirect 48 | github.com/containerd/console v1.0.5 // indirect 49 | github.com/fatih/color v1.16.0 // indirect 50 | github.com/fsnotify/fsnotify v1.9.0 // indirect 51 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 52 | github.com/godbus/dbus/v5 v5.1.0 // indirect 53 | github.com/google/uuid v1.6.0 // indirect 54 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 55 | github.com/hashicorp/go-retryablehttp v0.7.8 // indirect 56 | github.com/lithammer/fuzzysearch v1.1.8 // indirect 57 | github.com/mattn/go-runewidth v0.0.16 // indirect 58 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 59 | github.com/rivo/uniseg v0.4.7 // indirect 60 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 61 | github.com/sagikazarmark/locafero v0.12.0 // indirect 62 | github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect 63 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 64 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 65 | go.yaml.in/yaml/v3 v3.0.4 // indirect 66 | golang.org/x/crypto v0.42.0 // indirect 67 | ) 68 | 69 | //replace github.com/jonhadfield/gosn-v2 => ../gosn-v2 70 | -------------------------------------------------------------------------------- /healthcheck.go: -------------------------------------------------------------------------------- 1 | package sncli 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/briandowns/spinner" 9 | "github.com/gookit/color" 10 | "github.com/jonhadfield/gosn-v2/items" 11 | "github.com/jonhadfield/gosn-v2/session" 12 | ) 13 | 14 | type ItemsKeysHealthcheckInput struct { 15 | Session session.Session 16 | UseStdOut bool 17 | DeleteInvalid bool 18 | } 19 | 20 | func ItemKeysHealthcheck(input ItemsKeysHealthcheckInput) error { 21 | var so items.SyncOutput 22 | var err error 23 | var syncToken string 24 | 25 | // request all items from SN 26 | 27 | if !input.Session.Debug { 28 | prefix := color.HiWhite.Sprintf("retrieving items ") 29 | 30 | s := spinner.New(spinner.CharSets[14], spinnerDelay, spinner.WithWriter(os.Stdout)) 31 | if input.UseStdOut { 32 | s = spinner.New(spinner.CharSets[14], spinnerDelay, spinner.WithWriter(os.Stderr)) 33 | } 34 | 35 | s.Prefix = prefix 36 | s.Start() 37 | 38 | so, err = items.Sync(items.SyncInput{ 39 | Session: &input.Session, 40 | }) 41 | 42 | s.Stop() 43 | } else { 44 | so, err = items.Sync(items.SyncInput{ 45 | Session: &input.Session, 46 | }) 47 | } 48 | 49 | if err != nil { 50 | return err 51 | } 52 | 53 | syncToken = so.SyncToken 54 | 55 | // get a list of items keys and a count of items each one is used to encrypt 56 | 57 | var seenItemsKeys []string 58 | referencedItemsKeys := make(map[string]int) 59 | for x := range so.Items { 60 | if so.Items[x].Deleted { 61 | continue 62 | } 63 | switch so.Items[x].ContentType { 64 | case "SN|ItemsKey": 65 | // add to list of seen keys 66 | seenItemsKeys = append(seenItemsKeys, so.Items[x].UUID) 67 | 68 | if so.Items[x].UUID == "" { 69 | fmt.Printf("items key without UUID: %+v\n", so.Items[x]) 70 | } 71 | if so.Items[x].EncItemKey == "" { 72 | fmt.Printf("items key without enc_item_key: %+v\n", so.Items[x]) 73 | } 74 | 75 | default: 76 | if so.Items[x].ItemsKeyID != "" { 77 | // if an item has an items key id specified, then increment the count of how many 78 | // items the items key references 79 | referencedItemsKeys[so.Items[x].ItemsKeyID]++ 80 | } 81 | } 82 | } 83 | 84 | fmt.Println("existing Items Keys:") 85 | for x := range seenItemsKeys { 86 | fmt.Printf("- %s\n", seenItemsKeys[x]) 87 | } 88 | 89 | fmt.Println() 90 | 91 | fmt.Println("item references per Items Key:") 92 | for k, v := range referencedItemsKeys { 93 | if v != 0 { 94 | fmt.Printf("- %s | %d\n", k, v) 95 | } 96 | } 97 | 98 | fmt.Println() 99 | 100 | var itemsWithMissingKeys []string 101 | var encitemsNotSpecifyingItemsKeyID items.EncryptedItems 102 | var itemsKeysNotEncryptedWithCurrentMasterKey []string 103 | var itemsKeysInUse []string 104 | var version003Items int64 105 | // check for unused ItemsKeys 106 | for x := range so.Items { 107 | // skip deleted items 108 | if so.Items[x].Deleted { 109 | continue 110 | } 111 | 112 | // skip version 003 items as unsupported 113 | if strings.HasPrefix(so.Items[x].Content, "003") { 114 | version003Items++ 115 | 116 | continue 117 | } 118 | 119 | switch { 120 | case so.Items[x].ContentType == "SN|ItemsKey": 121 | var ik items.ItemsKey 122 | ik, err = so.Items[x].Decrypt(input.Session.MasterKey) 123 | if err != nil || ik.ItemsKey == "" { 124 | itemsKeysNotEncryptedWithCurrentMasterKey = append(itemsKeysNotEncryptedWithCurrentMasterKey, 125 | so.Items[x].UUID) 126 | } 127 | 128 | case !isEncryptedWithMasterKey(so.Items[x].ContentType): 129 | if so.Items[x].ItemsKeyID == "" { 130 | fmt.Printf("%s %s has no ItemsKeyID\n", so.Items[x].ContentType, so.Items[x].UUID) 131 | encitemsNotSpecifyingItemsKeyID = append(encitemsNotSpecifyingItemsKeyID, so.Items[x]) 132 | 133 | continue 134 | } 135 | itemsKeysInUse = append(itemsKeysInUse, so.Items[x].ItemsKeyID) 136 | if !itemsKeyExists(so.Items[x].ItemsKeyID, seenItemsKeys) { 137 | fmt.Printf("no matching items key found for %s %s specifying key: %s\n", 138 | so.Items[x].ContentType, 139 | so.Items[x].UUID, 140 | so.Items[x].ItemsKeyID) 141 | itemsWithMissingKeys = append(itemsWithMissingKeys, 142 | fmt.Sprintf("Type: %s UUID: %s| ItemsKeyID: %s", 143 | so.Items[x].ContentType, 144 | so.Items[x].UUID, 145 | so.Items[x].ItemsKeyID)) 146 | } 147 | } 148 | } 149 | 150 | if version003Items > 0 { 151 | fmt.Printf("items created using Standard Notes version 003 found: %d (export and import items to update to 004)\n", version003Items) 152 | } 153 | 154 | fmt.Println("unused Items Keys:") 155 | var numUnusedItemsKeys int64 156 | for x := range seenItemsKeys { 157 | var seen bool 158 | for y := range itemsKeysInUse { 159 | if seenItemsKeys[x] == itemsKeysInUse[y] { 160 | seen = true 161 | 162 | break 163 | } 164 | } 165 | if !seen { 166 | numUnusedItemsKeys++ 167 | fmt.Printf("- %s\n", seenItemsKeys[x]) 168 | } 169 | } 170 | if numUnusedItemsKeys == 0 { 171 | fmt.Println("none") 172 | } 173 | 174 | if len(encitemsNotSpecifyingItemsKeyID) > 0 { 175 | fmt.Println("no matching ItemsKey specified for these encrypted items:") 176 | for x := range encitemsNotSpecifyingItemsKeyID { 177 | fmt.Printf("- %s %s\n", encitemsNotSpecifyingItemsKeyID[x].ContentType, encitemsNotSpecifyingItemsKeyID[x].UUID) 178 | } 179 | } 180 | 181 | if len(encitemsNotSpecifyingItemsKeyID) > 0 && input.DeleteInvalid { 182 | fmt.Printf("wipe all encrypted items without items keys (account: %s)? ", 183 | input.Session.KeyParams.Identifier) 184 | 185 | var c string 186 | _, err = fmt.Scanln(&c) 187 | if err == nil && StringInSlice(c, []string{"y", "yes"}, false) { 188 | var itemsToDelete items.EncryptedItems 189 | for x := range encitemsNotSpecifyingItemsKeyID { 190 | itemToDelete := encitemsNotSpecifyingItemsKeyID[x] 191 | itemToDelete.Deleted = true 192 | itemsToDelete = append(itemsToDelete, itemToDelete) 193 | } 194 | 195 | so, err = items.Sync(items.SyncInput{ 196 | SyncToken: syncToken, 197 | Session: &input.Session, 198 | Items: itemsToDelete, 199 | }) 200 | if err != nil { 201 | return err 202 | } 203 | 204 | fmt.Printf("successfully deleted %d items\n", len(so.SavedItems)) 205 | } 206 | } 207 | 208 | return nil 209 | } 210 | 211 | func itemsKeyExists(uuid string, seenItemsKeys []string) bool { 212 | for x := range seenItemsKeys { 213 | if seenItemsKeys[x] == uuid { 214 | return true 215 | } 216 | } 217 | 218 | return false 219 | } 220 | 221 | func isEncryptedWithMasterKey(t string) bool { 222 | return t == "SN|ItemsKey" 223 | } 224 | 225 | func isUnsupportedType(t string) bool { 226 | return false 227 | // return strings.HasPrefix(t, "SF|") 228 | } 229 | -------------------------------------------------------------------------------- /helpers.go: -------------------------------------------------------------------------------- 1 | package sncli 2 | 3 | import ( 4 | "encoding/gob" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "strings" 9 | 10 | "github.com/jonhadfield/gosn-v2/items" 11 | ) 12 | 13 | func StringInSlice(inStr string, inSlice []string, matchCase bool) bool { 14 | for i := range inSlice { 15 | if matchCase { 16 | if strings.EqualFold(inStr, inSlice[i]) { 17 | return true 18 | } 19 | } else { 20 | if inStr == inSlice[i] { 21 | return true 22 | } 23 | } 24 | } 25 | 26 | return false 27 | } 28 | 29 | func outList(i []string, sep string) string { 30 | if len(i) == 0 { 31 | return "-" 32 | } 33 | 34 | return strings.Join(i, sep) 35 | } 36 | 37 | func writeGob(filePath string, object interface{}) error { 38 | file, err := os.Create(filePath) 39 | if err == nil { 40 | encoder := gob.NewEncoder(file) 41 | _ = encoder.Encode(object) 42 | } 43 | 44 | if file != nil { 45 | _ = file.Close() 46 | } 47 | 48 | return err 49 | } 50 | 51 | type EncryptedItemExport struct { 52 | UUID string `json:"uuid"` 53 | ItemsKeyID string `json:"items_key_id,omitempty"` 54 | Content string `json:"content"` 55 | ContentType string `json:"content_type"` 56 | // Deleted bool `json:"deleted"` 57 | EncItemKey string `json:"enc_item_key"` 58 | CreatedAt string `json:"created_at"` 59 | UpdatedAt string `json:"updated_at"` 60 | CreatedAtTimestamp int64 `json:"created_at_timestamp"` 61 | UpdatedAtTimestamp int64 `json:"updated_at_timestamp"` 62 | DuplicateOf *string `json:"duplicate_of"` 63 | } 64 | 65 | func writeJSON(i ExportConfig, items items.EncryptedItems) error { 66 | // prepare for export 67 | var itemsExport []EncryptedItemExport 68 | for x := range items { 69 | itemsExport = append(itemsExport, EncryptedItemExport{ 70 | UUID: items[x].UUID, 71 | ItemsKeyID: items[x].ItemsKeyID, 72 | Content: items[x].Content, 73 | // Deleted: items[x].Deleted, 74 | ContentType: items[x].ContentType, 75 | EncItemKey: items[x].EncItemKey, 76 | CreatedAt: items[x].CreatedAt, 77 | UpdatedAt: items[x].UpdatedAt, 78 | CreatedAtTimestamp: items[x].CreatedAtTimestamp, 79 | UpdatedAtTimestamp: items[x].UpdatedAtTimestamp, 80 | DuplicateOf: items[x].DuplicateOf, 81 | }) 82 | } 83 | 84 | file, err := os.Create(i.File) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | defer file.Close() 90 | 91 | var jsonExport []byte 92 | if err == nil { 93 | jsonExport, err = json.MarshalIndent(itemsExport, "", " ") 94 | } 95 | 96 | content := strings.Builder{} 97 | content.WriteString("{\n \"version\": \"004\",") 98 | content.WriteString("\n \"items\": ") 99 | content.Write(jsonExport) 100 | content.WriteString(",") 101 | 102 | // add keyParams 103 | content.WriteString("\n \"keyParams\": {") 104 | content.WriteString(fmt.Sprintf("\n \"identifier\": \"%s\",", i.Session.KeyParams.Identifier)) 105 | content.WriteString(fmt.Sprintf("\n \"version\": \"%s\",", i.Session.KeyParams.Version)) 106 | content.WriteString(fmt.Sprintf("\n \"origination\": \"%s\",", i.Session.KeyParams.Origination)) 107 | content.WriteString(fmt.Sprintf("\n \"created\": \"%s\",", i.Session.KeyParams.Created)) 108 | content.WriteString(fmt.Sprintf("\n \"pw_nonce\": \"%s\"", i.Session.KeyParams.PwNonce)) 109 | content.WriteString("\n }") 110 | 111 | content.WriteString("\n}") 112 | _, err = file.WriteString(content.String()) 113 | 114 | return err 115 | } 116 | 117 | func readGob(filePath string, object interface{}) error { 118 | file, err := os.Open(filePath) 119 | if err == nil { 120 | decoder := gob.NewDecoder(file) 121 | err = decoder.Decode(object) 122 | } 123 | 124 | _ = file.Close() 125 | 126 | return err 127 | } 128 | 129 | type EncryptedItemsFile struct { 130 | Items items.EncryptedItems `json:"items"` 131 | } 132 | 133 | func readJSON(filePath string) (items.EncryptedItems, error) { 134 | file, err := os.ReadFile(filePath) 135 | if err != nil { 136 | return nil, fmt.Errorf("%w failed to open: %s", err, filePath) 137 | } 138 | 139 | var eif EncryptedItemsFile 140 | 141 | if err = json.Unmarshal(file, &eif); err != nil { 142 | return nil, fmt.Errorf("failed to unmarshall json: %w", err) 143 | } 144 | 145 | return eif.Items, nil 146 | } 147 | 148 | func ItemRefsToYaml(irs []items.ItemReference) []ItemReferenceYAML { 149 | var iRefs []ItemReferenceYAML 150 | 151 | for _, ref := range irs { 152 | iRef := ItemReferenceYAML{ 153 | UUID: ref.UUID, 154 | ContentType: ref.ContentType, 155 | ReferenceType: ref.ReferenceType, 156 | } 157 | iRefs = append(iRefs, iRef) 158 | } 159 | 160 | return iRefs 161 | } 162 | 163 | func ItemRefsToJSON(irs []items.ItemReference) []ItemReferenceJSON { 164 | var iRefs []ItemReferenceJSON 165 | 166 | for _, ref := range irs { 167 | iRef := ItemReferenceJSON{ 168 | UUID: ref.UUID, 169 | ContentType: ref.ContentType, 170 | ReferenceType: ref.ReferenceType, 171 | } 172 | iRefs = append(iRefs, iRef) 173 | } 174 | 175 | return iRefs 176 | } 177 | 178 | func CommaSplit(i string) []string { 179 | // split i 180 | o := strings.Split(i, ",") 181 | // strip leading and trailing space 182 | var s []string 183 | 184 | for _, i := range o { 185 | ti := strings.TrimSpace(i) 186 | if len(ti) > 0 { 187 | s = append(s, strings.TrimSpace(i)) 188 | } 189 | } 190 | 191 | if len(s) == 1 && len(s[0]) == 0 { 192 | return nil 193 | } 194 | 195 | return s 196 | } 197 | 198 | func RemoveDeleted(in items.Items) items.Items { 199 | var out items.Items 200 | for _, i := range in { 201 | if !i.IsDeleted() { 202 | out = append(out, i) 203 | } 204 | } 205 | 206 | return out 207 | } 208 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package sncli 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/briandowns/spinner" 7 | "github.com/gookit/color" 8 | "github.com/jonhadfield/gosn-v2/cache" 9 | "github.com/jonhadfield/gosn-v2/common" 10 | "github.com/jonhadfield/gosn-v2/items" 11 | ) 12 | 13 | const ( 14 | timeLayout = "2006-01-02T15:04:05.000Z" 15 | SNServerURL = "https://api.standardnotes.com" 16 | SNPageSize = 600 17 | SNAppName = "sn-cli" 18 | MinPasswordLength = 8 19 | ) 20 | 21 | type ItemReferenceYAML struct { 22 | UUID string `yaml:"uuid"` 23 | ContentType string `yaml:"content_type"` 24 | ReferenceType string `yaml:"reference_type,omitempty"` 25 | } 26 | 27 | type ItemReferenceJSON struct { 28 | UUID string `json:"uuid"` 29 | ContentType string `json:"content_type"` 30 | ReferenceType string `json:"reference_type,omitempty"` 31 | } 32 | 33 | type OrgStandardNotesSNDetailJSON struct { 34 | ClientUpdatedAt string `json:"client_updated_at"` 35 | PrefersPlainEditor bool `json:"prefersPlainEditor"` 36 | Pinned bool `json:"pinned"` 37 | } 38 | 39 | type OrgStandardNotesSNComponentsDetailJSON map[string]interface{} 40 | 41 | type OrgStandardNotesSNDetailYAML struct { 42 | ClientUpdatedAt string `yaml:"client_updated_at"` 43 | } 44 | 45 | type AppDataContentYAML struct { 46 | OrgStandardNotesSN OrgStandardNotesSNDetailYAML `yaml:"org.standardnotes.sn"` 47 | OrgStandardNotesSNComponents items.OrgStandardNotesSNComponentsDetail `yaml:"org.standardnotes.sn.components,omitempty"` 48 | } 49 | 50 | type AppDataContentJSON struct { 51 | OrgStandardNotesSN OrgStandardNotesSNDetailJSON `json:"org.standardnotes.sn"` 52 | OrgStandardNotesSNComponents items.OrgStandardNotesSNComponentsDetail `json:"org.standardnotes.sn.components,omitempty"` 53 | } 54 | 55 | type TagContentYAML struct { 56 | Title string `yaml:"title"` 57 | ItemReferences []ItemReferenceYAML `yaml:"references"` 58 | AppData AppDataContentYAML `yaml:"appData"` 59 | } 60 | 61 | type TagContentJSON struct { 62 | Title string `json:"title"` 63 | ItemReferences []ItemReferenceJSON `json:"references"` 64 | AppData AppDataContentJSON `json:"appData"` 65 | } 66 | 67 | type SettingContentYAML struct { 68 | Title string `yaml:"title"` 69 | ItemReferences []ItemReferenceYAML `yaml:"references"` 70 | AppData AppDataContentYAML `yaml:"appData"` 71 | } 72 | 73 | type SettingContentJSON struct { 74 | Title string `json:"title"` 75 | ItemReferences []ItemReferenceJSON `json:"references"` 76 | AppData AppDataContentJSON `json:"appData"` 77 | } 78 | 79 | type NoteContentYAML struct { 80 | Title string `yaml:"title"` 81 | Text string `json:"text"` 82 | ItemReferences []ItemReferenceYAML `yaml:"references"` 83 | AppData AppDataContentYAML `yaml:"appData"` 84 | EditorIdentifier string `yaml:"editorIdentifier"` 85 | PreviewPlain string `yaml:"preview_plain"` 86 | PreviewHtml string `yaml:"preview_html"` 87 | Spellcheck bool `yaml:"spellcheck"` 88 | Trashed *bool `yaml:"trashed,omitempty"` 89 | } 90 | 91 | type NoteContentJSON struct { 92 | Title string `json:"title"` 93 | Text string `json:"text"` 94 | ItemReferences []ItemReferenceJSON `json:"references"` 95 | AppData AppDataContentJSON `json:"appData"` 96 | EditorIdentifier string `json:"editorIdentifier"` 97 | PreviewPlain string `json:"preview_plain"` 98 | PreviewHtml string `json:"preview_html"` 99 | Spellcheck bool `json:"spellcheck"` 100 | Trashed *bool `json:"trashed,omitempty"` 101 | } 102 | 103 | type TagJSON struct { 104 | UUID string `json:"uuid"` 105 | Content TagContentJSON `json:"content"` 106 | ContentType string `json:"content_type"` 107 | CreatedAt string `json:"created_at"` 108 | UpdatedAt string `json:"updated_at"` 109 | } 110 | 111 | type TagYAML struct { 112 | UUID string `yaml:"uuid"` 113 | Content TagContentYAML `yaml:"content"` 114 | ContentType string `yaml:"content_type"` 115 | CreatedAt string `yaml:"created_at"` 116 | UpdatedAt string `yaml:"updated_at"` 117 | } 118 | 119 | type NoteJSON struct { 120 | UUID string `json:"uuid"` 121 | Content NoteContentJSON `json:"content"` 122 | ContentType string `json:"content_type"` 123 | CreatedAt string `json:"created_at"` 124 | UpdatedAt string `json:"updated_at"` 125 | } 126 | 127 | type SettingYAML struct { 128 | UUID string `yaml:"uuid"` 129 | Content SettingContentYAML `yaml:"content"` 130 | ContentType string `yaml:"content_type"` 131 | CreatedAt string `yaml:"created_at"` 132 | UpdatedAt string `yaml:"updated_at"` 133 | } 134 | 135 | type SettingJSON struct { 136 | UUID string `json:"uuid"` 137 | Content SettingContentJSON `json:"content"` 138 | ContentType string `json:"content_type"` 139 | CreatedAt string `json:"created_at"` 140 | UpdatedAt string `json:"updated_at"` 141 | } 142 | 143 | type NoteYAML struct { 144 | UUID string `yaml:"uuid"` 145 | Content NoteContentYAML `yaml:"content"` 146 | ContentType string `yaml:"content_type"` 147 | CreatedAt string `yaml:"created_at"` 148 | UpdatedAt string `yaml:"updated_at"` 149 | } 150 | 151 | type TagItemsConfig struct { 152 | Session *cache.Session 153 | FindTitle string 154 | FindText string 155 | FindTag string 156 | NewTags []string 157 | Replace bool 158 | IgnoreCase bool 159 | Debug bool 160 | } 161 | 162 | type GetItemsConfig struct { 163 | Session *cache.Session 164 | Filters items.ItemFilters 165 | Output string 166 | Debug bool 167 | } 168 | 169 | type AddTagsInput struct { 170 | Session *cache.Session 171 | Tags []string 172 | Parent string 173 | ParentUUID string 174 | Debug bool 175 | Replace bool 176 | } 177 | 178 | type AddTagsOutput struct { 179 | Added, Existing []string 180 | } 181 | 182 | type GetTagConfig struct { 183 | Session *cache.Session 184 | Filters items.ItemFilters 185 | Output string 186 | Debug bool 187 | } 188 | 189 | type GetSettingsConfig struct { 190 | Session *cache.Session 191 | Filters items.ItemFilters 192 | Output string 193 | Debug bool 194 | } 195 | 196 | type GetNoteConfig struct { 197 | Session *cache.Session 198 | Filters items.ItemFilters 199 | NoteTitles []string 200 | TagTitles []string 201 | TagUUIDs []string 202 | PageSize int 203 | BatchSize int 204 | Debug bool 205 | } 206 | 207 | type DeleteTagConfig struct { 208 | Session *cache.Session 209 | Email string 210 | TagTitles []string 211 | TagUUIDs []string 212 | Regex bool 213 | Debug bool 214 | } 215 | 216 | type ListChecklistsInput struct { 217 | Session *cache.Session 218 | Debug bool 219 | } 220 | 221 | type ShowChecklistInput struct { 222 | Session *cache.Session 223 | Title string 224 | UUID string 225 | Debug bool 226 | } 227 | 228 | type AddNoteInput struct { 229 | Session *cache.Session 230 | Title string 231 | Text string 232 | FilePath string 233 | Tags []string 234 | Replace bool 235 | Debug bool 236 | } 237 | 238 | type DeleteItemConfig struct { 239 | Session *cache.Session 240 | NoteTitles []string 241 | NoteText string 242 | ItemsUUIDs []string 243 | Regex bool 244 | Debug bool 245 | } 246 | 247 | type DeleteNoteConfig struct { 248 | Session *cache.Session 249 | NoteTitles []string 250 | NoteText string 251 | NoteUUIDs []string 252 | Regex bool 253 | Debug bool 254 | } 255 | 256 | type WipeConfig struct { 257 | Session *cache.Session 258 | UseStdOut bool 259 | Debug bool 260 | Everything bool 261 | } 262 | 263 | type StatsConfig struct { 264 | Session cache.Session 265 | } 266 | 267 | func referenceExists(tag items.Tag, refID string) bool { 268 | for _, ref := range tag.Content.References() { 269 | if ref.UUID == refID { 270 | return true 271 | } 272 | } 273 | 274 | return false 275 | } 276 | 277 | func filterEncryptedItemsByTypes(ei items.EncryptedItems, types []string) items.EncryptedItems { 278 | var o items.EncryptedItems 279 | for _, i := range ei { 280 | if StringInSlice(i.ContentType, types, true) { 281 | o = append(o, i) 282 | } 283 | } 284 | 285 | return o 286 | } 287 | 288 | func filterItemsByTypes(ei items.Items, types []string) items.Items { 289 | var o items.Items 290 | for _, i := range ei { 291 | if StringInSlice(i.GetContentType(), types, true) { 292 | o = append(o, i) 293 | } 294 | } 295 | 296 | return o 297 | } 298 | 299 | func filterCacheItemsByTypes(ei cache.Items, types []string) cache.Items { 300 | var o cache.Items 301 | for _, i := range ei { 302 | if StringInSlice(i.ContentType, types, true) { 303 | o = append(o, i) 304 | } 305 | } 306 | 307 | return o 308 | } 309 | 310 | var supportedContentTypes = []string{common.SNItemTypeNote, common.SNItemTypeTag, common.SNItemTypeComponent} 311 | 312 | func (i *WipeConfig) Run() (int, error) { 313 | i.Session.RemoveDB() 314 | if !i.Session.Debug && i.UseStdOut { 315 | prefix := color.HiWhite.Sprintf("wiping ") 316 | 317 | s := spinner.New(spinner.CharSets[14], spinnerDelay, spinner.WithWriter(os.Stdout)) 318 | if i.UseStdOut { 319 | s = spinner.New(spinner.CharSets[14], spinnerDelay, spinner.WithWriter(os.Stderr)) 320 | } 321 | 322 | s.Prefix = prefix 323 | s.Start() 324 | 325 | deleted, err := items.DeleteContent(i.Session.Session, i.Everything) 326 | 327 | s.Stop() 328 | 329 | return deleted, err 330 | } 331 | 332 | return items.DeleteContent(i.Session.Session, i.Everything) 333 | } 334 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package sncli 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "strconv" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/jonhadfield/gosn-v2/auth" 13 | "github.com/jonhadfield/gosn-v2/cache" 14 | "github.com/jonhadfield/gosn-v2/common" 15 | "github.com/jonhadfield/gosn-v2/items" 16 | "github.com/jonhadfield/gosn-v2/session" 17 | ) 18 | 19 | var ( 20 | testSession *cache.Session 21 | gTtestSession *session.Session 22 | testUserEmail string 23 | testUserPassword string 24 | ) 25 | 26 | func localTestMain() { 27 | localServer := "http://ramea:3000" 28 | testUserEmail = fmt.Sprintf("ramea-%s", strconv.FormatInt(time.Now().UnixNano(), 16)) 29 | testUserPassword = "secretsanta" 30 | 31 | rInput := auth.RegisterInput{ 32 | Password: testUserPassword, 33 | Email: testUserEmail, 34 | APIServer: localServer, 35 | Version: "004", 36 | Debug: true, 37 | } 38 | 39 | _, err := rInput.Register() 40 | if err != nil { 41 | panic(fmt.Sprintf("failed to register with: %s", localServer)) 42 | } 43 | 44 | signIn(localServer, testUserEmail, testUserPassword) 45 | } 46 | 47 | func signIn(server, email, password string) { 48 | // Enable debugging if requested 49 | debug := os.Getenv("SN_DEBUG") == "true" 50 | 51 | ts, err := auth.CliSignIn(email, password, server, debug) 52 | if err != nil { 53 | log.Fatal(err) 54 | } 55 | 56 | if server == "" { 57 | server = SNServerURL 58 | } 59 | 60 | // Enable schema validation if requested 61 | schemaValidation := os.Getenv("SN_SCHEMA_VALIDATION") == "yes" || os.Getenv("SN_SCHEMA_VALIDATION") == "true" 62 | 63 | // Disable logger if not debugging 64 | if !debug { 65 | ts.HTTPClient.Logger = nil 66 | } 67 | 68 | gTtestSession = &session.Session{ 69 | Debug: debug, 70 | HTTPClient: ts.HTTPClient, 71 | SchemaValidation: schemaValidation, 72 | Server: server, 73 | FilesServerUrl: ts.FilesServerUrl, 74 | Token: ts.Token, 75 | MasterKey: ts.MasterKey, 76 | // ItemsKeys: nil, 77 | // DefaultItemsKey: session.SessionItemsKey{}, 78 | KeyParams: ts.KeyParams, 79 | AccessToken: ts.AccessToken, 80 | RefreshToken: ts.RefreshToken, 81 | AccessExpiration: ts.AccessExpiration, 82 | RefreshExpiration: ts.RefreshExpiration, 83 | ReadOnlyAccess: ts.ReadOnlyAccess, 84 | PasswordNonce: ts.PasswordNonce, 85 | Schemas: nil, 86 | } 87 | 88 | testSession = &cache.Session{ 89 | Session: gTtestSession, 90 | CacheDB: nil, 91 | CacheDBPath: "", 92 | } 93 | } 94 | 95 | func TestMain(m *testing.M) { 96 | // if os.Getenv("SN_SERVER") == "" || strings.Contains(os.Getenv("SN_SERVER"), "ramea") { 97 | if strings.Contains(os.Getenv("SN_SERVER"), "ramea") { 98 | localTestMain() 99 | } else { 100 | email := os.Getenv("SN_EMAIL") 101 | password := os.Getenv("SN_PASSWORD") 102 | if email == "" { 103 | email = "gosn-v2-202509231858@lessknown.co.uk" 104 | } 105 | if password == "" { 106 | password = "gosn-v2-202509231858@lessknown.co.uk" 107 | } 108 | signIn(SNServerURL, email, password) 109 | } 110 | 111 | // Add delay to prevent rate limiting during initial sync 112 | time.Sleep(2 * time.Second) 113 | 114 | si := items.SyncInput{ 115 | Session: gTtestSession, 116 | } 117 | 118 | _, syncErr := items.Sync(si) 119 | if syncErr != nil { 120 | log.Printf("WARNING: Initial sync failed: %v", syncErr) 121 | 122 | newKey, createErr := items.CreateItemsKey() 123 | if createErr != nil { 124 | log.Printf("ERROR: Failed to create new items key: %v", createErr) 125 | gTtestSession.DefaultItemsKey.ItemsKey = "test-placeholder-key" 126 | } else { 127 | gTtestSession.DefaultItemsKey = session.SessionItemsKey{ 128 | ItemsKey: newKey.Content.ItemsKey, 129 | UUID: newKey.UUID, 130 | } 131 | gTtestSession.ItemsKeys = append(gTtestSession.ItemsKeys, gTtestSession.DefaultItemsKey) 132 | } 133 | } 134 | 135 | if gTtestSession.DefaultItemsKey.ItemsKey == "" { 136 | panic("failed in TestMain due to empty default items key") 137 | } 138 | if strings.TrimSpace(gTtestSession.Server) == "" { 139 | panic("failed in TestMain due to empty server") 140 | } 141 | 142 | var importErr error 143 | testSession, importErr = cache.ImportSession(&auth.SignInResponseDataSession{ 144 | Debug: gTtestSession.Debug, 145 | HTTPClient: gTtestSession.HTTPClient, 146 | SchemaValidation: gTtestSession.SchemaValidation, 147 | Server: gTtestSession.Server, 148 | FilesServerUrl: gTtestSession.FilesServerUrl, 149 | Token: gTtestSession.Token, 150 | MasterKey: gTtestSession.MasterKey, 151 | KeyParams: gTtestSession.KeyParams, 152 | AccessToken: gTtestSession.AccessToken, 153 | RefreshToken: gTtestSession.RefreshToken, 154 | AccessExpiration: gTtestSession.AccessExpiration, 155 | RefreshExpiration: gTtestSession.RefreshExpiration, 156 | ReadOnlyAccess: gTtestSession.ReadOnlyAccess, 157 | PasswordNonce: gTtestSession.PasswordNonce, 158 | }, "") 159 | if importErr != nil { 160 | return 161 | } 162 | 163 | // Copy the items keys from gTtestSession to testSession 164 | testSession.Session.DefaultItemsKey = gTtestSession.DefaultItemsKey 165 | testSession.Session.ItemsKeys = gTtestSession.ItemsKeys 166 | 167 | testSession.CacheDBPath, importErr = cache.GenCacheDBPath(*testSession, "", common.LibName) 168 | if importErr != nil { 169 | panic(importErr) 170 | } 171 | 172 | // Run the tests 173 | exitCode := m.Run() 174 | 175 | // Clean up before exiting 176 | // Close any open cache database connections 177 | if testSession != nil && testSession.CacheDB != nil { 178 | if err := testSession.CacheDB.Close(); err != nil { 179 | log.Printf("WARNING: Failed to close cache database: %v", err) 180 | } 181 | } 182 | 183 | // Remove the cache database file 184 | if testSession != nil && testSession.CacheDBPath != "" { 185 | if err := os.Remove(testSession.CacheDBPath); err != nil && !os.IsNotExist(err) { 186 | log.Printf("WARNING: Failed to remove cache database file: %v", err) 187 | } 188 | } 189 | 190 | os.Exit(exitCode) 191 | } 192 | 193 | // prevent throttling when using official server. 194 | func testDelay() { 195 | if strings.Contains(os.Getenv("SN_SERVER"), "api.standardnotes.com") { 196 | time.Sleep(5 * time.Second) 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /note.go: -------------------------------------------------------------------------------- 1 | package sncli 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/asdine/storm/v3/q" 11 | "github.com/jonhadfield/gosn-v2/cache" 12 | "github.com/jonhadfield/gosn-v2/common" 13 | "github.com/jonhadfield/gosn-v2/items" 14 | ) 15 | 16 | func (i *AddNoteInput) Run() error { 17 | // get DB 18 | var syncToken string 19 | 20 | ani := addNoteInput{ 21 | noteTitle: i.Title, 22 | noteText: i.Text, 23 | tagTitles: i.Tags, 24 | filePath: i.FilePath, 25 | session: i.Session, 26 | replace: i.Replace, 27 | } 28 | 29 | newNoteUUID, err := addNote(ani) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | if len(ani.tagTitles) > 0 { 35 | if err = tagNotes(tagNotesInput{ 36 | matchNoteUUIDs: []string{newNoteUUID}, 37 | syncToken: syncToken, 38 | session: i.Session, 39 | newTags: i.Tags, 40 | replace: i.Replace, 41 | }); err != nil { 42 | return err 43 | } 44 | } 45 | 46 | return nil 47 | } 48 | 49 | type addNoteInput struct { 50 | session *cache.Session 51 | noteTitle string 52 | noteText string 53 | filePath string 54 | tagTitles []string 55 | replace bool 56 | } 57 | 58 | func loadNoteContentFromFile(filePath string) (string, error) { 59 | file, err := os.Open(filePath) 60 | if err != nil { 61 | return "", fmt.Errorf("%w failed to open: %s", err, filePath) 62 | } 63 | 64 | b, err := io.ReadAll(file) 65 | if err != nil { 66 | return "", fmt.Errorf("%w failed to read: %s", err, filePath) 67 | } 68 | 69 | return string(b), nil 70 | } 71 | 72 | func addNote(i addNoteInput) (string, error) { 73 | var err error 74 | 75 | // if file path provided, try loading content as note text 76 | if i.filePath != "" { 77 | if i.noteText, err = loadNoteContentFromFile(i.filePath); err != nil { 78 | return "", err 79 | } 80 | 81 | if i.noteTitle == "" { 82 | i.noteTitle = filepath.Base(i.filePath) 83 | } 84 | } 85 | 86 | var noteToAdd items.Note 87 | var noteUUID string 88 | 89 | si := cache.SyncInput{ 90 | Session: i.session, 91 | Close: true, 92 | } 93 | 94 | var so cache.SyncOutput 95 | 96 | so, err = Sync(si, true) 97 | if err != nil { 98 | return "", err 99 | } 100 | 101 | // if we're replacing, then retrieve note to update 102 | if i.replace { 103 | gnc := GetNoteConfig{ 104 | Session: i.session, 105 | // Filters: items.ItemFilters{}, 106 | Filters: items.ItemFilters{ 107 | MatchAny: false, 108 | Filters: []items.Filter{ 109 | { 110 | Type: common.SNItemTypeNote, 111 | Key: "Title", 112 | Comparison: "==", 113 | Value: i.noteTitle, 114 | }, 115 | }, 116 | }, 117 | } 118 | 119 | var gi items.Items 120 | 121 | gi, err = gnc.Run() 122 | if err != nil { 123 | return "", err 124 | } 125 | 126 | switch len(gi) { 127 | case 0: 128 | return "", errors.New("failed to find existing note to replace") 129 | case 1: 130 | noteToAdd = gi.Notes()[0] 131 | noteToAdd.Content.SetText(i.noteText) 132 | default: 133 | return "", errors.New("multiple notes found with that title") 134 | } 135 | } else { 136 | noteToAdd, err = items.NewNote(i.noteTitle, i.noteText, nil) 137 | if err != nil { 138 | return "", err 139 | } 140 | noteUUID = noteToAdd.UUID 141 | } 142 | 143 | si = cache.SyncInput{ 144 | Session: i.session, 145 | Close: false, 146 | } 147 | 148 | so, err = Sync(si, true) 149 | if err != nil { 150 | return "", err 151 | } 152 | 153 | var allEncTags cache.Items 154 | 155 | query := so.DB.Select(q.And(q.Eq("ContentType", common.SNItemTypeTag), q.Eq("Deleted", false))) 156 | 157 | err = query.Find(&allEncTags) 158 | // it's ok if there are no tags, so only error if something else went wrong 159 | if err != nil && err.Error() != "not found" { 160 | return "", err 161 | } 162 | 163 | if err = cache.SaveNotes(i.session, so.DB, items.Notes{noteToAdd}, false); err != nil { 164 | return "", err 165 | } 166 | 167 | _ = so.DB.Close() 168 | 169 | pii := cache.SyncInput{ 170 | Session: i.session, 171 | Close: false, 172 | } 173 | 174 | so, err = Sync(pii, true) 175 | if err != nil { 176 | return "", err 177 | } 178 | 179 | defer func() { 180 | _ = so.DB.Close() 181 | }() 182 | 183 | if len(i.tagTitles) > 0 { 184 | _ = so.DB.Close() 185 | tni := tagNotesInput{ 186 | session: i.session, 187 | matchNoteUUIDs: []string{noteToAdd.UUID}, 188 | newTags: i.tagTitles, 189 | } 190 | 191 | err = tagNotes(tni) 192 | if err != nil { 193 | return "", err 194 | } 195 | } 196 | 197 | return noteUUID, err 198 | } 199 | 200 | func (i *DeleteItemConfig) Run() (int, error) { 201 | noDeleted, err := deleteItems(i.Session, []string{}, "", i.ItemsUUIDs, i.Regex) 202 | 203 | return noDeleted, err 204 | } 205 | 206 | func (i *DeleteNoteConfig) Run() (int, error) { 207 | return deleteNotes(i.Session, i.NoteTitles, i.NoteText, i.NoteUUIDs, i.Regex) 208 | } 209 | 210 | func (i *GetNoteConfig) Run() (items.Items, error) { 211 | var so cache.SyncOutput 212 | var err error 213 | so, err = Sync(cache.SyncInput{ 214 | Session: i.Session, 215 | }, true) 216 | if err != nil { 217 | return nil, err 218 | } 219 | 220 | var allPersistedItems cache.Items 221 | 222 | err = so.DB.All(&allPersistedItems) 223 | if err != nil { 224 | return nil, fmt.Errorf("getting items from db: %w", err) 225 | } 226 | 227 | defer func() { 228 | _ = so.DB.Close() 229 | }() 230 | 231 | items, err := allPersistedItems.ToItems(i.Session) 232 | if err != nil { 233 | return nil, err 234 | } 235 | 236 | items.Filter(i.Filters) 237 | 238 | return items, nil 239 | } 240 | 241 | func deleteNotes(session *cache.Session, noteTitles []string, noteText string, noteUUIDs []string, regex bool) (int, error) { 242 | var err error 243 | var getNotesFilters []items.Filter 244 | 245 | switch { 246 | case len(noteTitles) > 0: 247 | for _, title := range noteTitles { 248 | comparison := "==" 249 | if regex { 250 | comparison = "~" 251 | } 252 | 253 | getNotesFilters = append(getNotesFilters, items.Filter{ 254 | Key: "Title", 255 | Value: title, 256 | Comparison: comparison, 257 | Type: common.SNItemTypeNote, 258 | }) 259 | } 260 | case noteText != "": 261 | comparison := "==" 262 | if regex { 263 | comparison = "~" 264 | } 265 | 266 | getNotesFilters = append(getNotesFilters, items.Filter{ 267 | Key: "Text", 268 | Value: noteText, 269 | Comparison: comparison, 270 | Type: common.SNItemTypeNote, 271 | }) 272 | case len(noteUUIDs) > 0: 273 | for _, uuid := range noteUUIDs { 274 | getNotesFilters = append(getNotesFilters, items.Filter{ 275 | Key: "UUID", 276 | Value: uuid, 277 | Comparison: "==", 278 | Type: common.SNItemTypeNote, 279 | }) 280 | } 281 | } 282 | 283 | itemFilter := items.ItemFilters{ 284 | Filters: getNotesFilters, 285 | MatchAny: true, 286 | } 287 | 288 | getItemsInput := cache.SyncInput{ 289 | Session: session, 290 | } 291 | 292 | var gio cache.SyncOutput 293 | 294 | gio, err = Sync(getItemsInput, true) 295 | if err != nil { 296 | return 0, err 297 | } 298 | 299 | var allPersistedItems cache.Items 300 | 301 | err = gio.DB.All(&allPersistedItems) 302 | if err != nil { 303 | return 0, fmt.Errorf("getting items from db: %w", err) 304 | } 305 | 306 | var notes items.Items 307 | 308 | notes, err = allPersistedItems.ToItems(session) 309 | if err != nil { 310 | return 0, err 311 | } 312 | 313 | notes.Filter(itemFilter) 314 | 315 | var notesToDelete items.Notes 316 | 317 | for _, item := range notes { 318 | if item.GetContentType() != common.SNItemTypeNote { 319 | panic(fmt.Sprintf("got a non-note item in the notes list: %s", item.GetContentType())) 320 | } 321 | note := item.(*items.Note) 322 | if note.GetContent() != nil { 323 | note.Content.SetText("") 324 | note.SetDeleted(true) 325 | notesToDelete = append(notesToDelete, *note) 326 | } 327 | } 328 | 329 | if notesToDelete == nil || len(notesToDelete) == 0 { 330 | // close db as we're not going to save anything 331 | _ = session.CacheDB.Close() 332 | 333 | return 0, nil 334 | } 335 | 336 | if err = cache.SaveNotes(session, gio.DB, notesToDelete, true); err != nil { 337 | return 0, err 338 | } 339 | 340 | pii := cache.SyncInput{ 341 | Session: session, 342 | Close: true, 343 | } 344 | 345 | _, err = Sync(pii, true) 346 | if err != nil { 347 | return 0, err 348 | } 349 | 350 | return len(notesToDelete), err 351 | } 352 | 353 | func deleteItems(session *cache.Session, noteTitles []string, noteText string, itemUUIDs []string, regex bool) (int, error) { 354 | var err error 355 | var getItemsFilters []items.Filter 356 | 357 | switch { 358 | case len(noteTitles) > 0: 359 | for _, title := range noteTitles { 360 | comparison := "==" 361 | if regex { 362 | comparison = "~" 363 | } 364 | 365 | getItemsFilters = append(getItemsFilters, items.Filter{ 366 | Key: "Title", 367 | Value: title, 368 | Comparison: comparison, 369 | Type: common.SNItemTypeNote, 370 | }) 371 | } 372 | case noteText != "": 373 | comparison := "==" 374 | if regex { 375 | comparison = "~" 376 | } 377 | 378 | getItemsFilters = append(getItemsFilters, items.Filter{ 379 | Key: "Text", 380 | Value: noteText, 381 | Comparison: comparison, 382 | Type: common.SNItemTypeNote, 383 | }) 384 | case len(itemUUIDs) > 0: 385 | for _, uuid := range itemUUIDs { 386 | getItemsFilters = append(getItemsFilters, items.Filter{ 387 | Key: "UUID", 388 | Value: uuid, 389 | Comparison: "==", 390 | Type: "Anything", 391 | }) 392 | } 393 | } 394 | 395 | itemFilter := items.ItemFilters{ 396 | Filters: getItemsFilters, 397 | MatchAny: true, 398 | } 399 | 400 | getItemsInput := cache.SyncInput{ 401 | Session: session, 402 | } 403 | 404 | var gio cache.SyncOutput 405 | 406 | gio, err = Sync(getItemsInput, true) 407 | if err != nil { 408 | return 0, err 409 | } 410 | 411 | var allPersistedItems cache.Items 412 | 413 | err = gio.DB.All(&allPersistedItems) 414 | if err != nil { 415 | return 0, fmt.Errorf("getting items from db: %w", err) 416 | } 417 | 418 | var pItems items.Items 419 | 420 | pItems, err = allPersistedItems.ToItems(session) 421 | if err != nil { 422 | return 0, err 423 | } 424 | 425 | pItems.Filter(itemFilter) 426 | var itemsToDelete items.Items 427 | 428 | for _, pItem := range pItems { 429 | pItem.SetDeleted(true) 430 | itemsToDelete = append(itemsToDelete, pItem) 431 | } 432 | 433 | if itemsToDelete == nil { 434 | return 0, nil 435 | } 436 | 437 | if err = cache.SaveItems(session, gio.DB, itemsToDelete, true); err != nil { 438 | return 0, err 439 | } 440 | 441 | pii := cache.SyncInput{ 442 | Session: session, 443 | Close: true, 444 | } 445 | 446 | _, err = Sync(pii, true) 447 | if err != nil { 448 | return 0, err 449 | } 450 | 451 | return len(itemsToDelete), err 452 | } 453 | -------------------------------------------------------------------------------- /register.go: -------------------------------------------------------------------------------- 1 | package sncli 2 | 3 | import "github.com/jonhadfield/gosn-v2/auth" 4 | 5 | type RegisterConfig struct { 6 | Email string 7 | Password string 8 | APIServer string 9 | Debug bool 10 | } 11 | 12 | func (i *RegisterConfig) Run() error { 13 | registerInput := auth.RegisterInput{ 14 | Email: i.Email, 15 | Password: i.Password, 16 | APIServer: i.APIServer, 17 | Debug: i.Debug, 18 | } 19 | 20 | _, err := registerInput.Register() 21 | 22 | return err 23 | } 24 | -------------------------------------------------------------------------------- /settings.go: -------------------------------------------------------------------------------- 1 | package sncli 2 | 3 | import ( 4 | "github.com/jonhadfield/gosn-v2/cache" 5 | "github.com/jonhadfield/gosn-v2/items" 6 | ) 7 | 8 | func (i *GetSettingsConfig) Run() (items.Items, error) { 9 | getItemsInput := cache.SyncInput{ 10 | Session: i.Session, 11 | } 12 | 13 | var so cache.SyncOutput 14 | 15 | so, err := Sync(getItemsInput, true) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | var allPersistedItems cache.Items 21 | 22 | if err = so.DB.All(&allPersistedItems); err != nil { 23 | return nil, err 24 | } 25 | 26 | items, err := allPersistedItems.ToItems(i.Session) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | items.Filter(i.Filters) 32 | 33 | return items, nil 34 | } 35 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=jonhadfield_sn-cli 2 | sonar.organization=jonhadfield 3 | 4 | # This is the name and version displayed in the SonarCloud UI. 5 | #sonar.projectName=sn-cli 6 | #sonar.projectVersion=1.0 7 | 8 | # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. 9 | #sonar.sources=. 10 | 11 | # Encoding of the source code. Default is default system encoding 12 | #sonar.sourceEncoding=UTF-8 -------------------------------------------------------------------------------- /stats.go: -------------------------------------------------------------------------------- 1 | package sncli 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | "time" 8 | 9 | "github.com/alexeyco/simpletable" 10 | "github.com/asdine/storm/v3" 11 | "github.com/dustin/go-humanize" 12 | 13 | "github.com/gookit/color" 14 | "github.com/jonhadfield/gosn-v2/cache" 15 | "github.com/jonhadfield/gosn-v2/common" 16 | "github.com/jonhadfield/gosn-v2/items" 17 | "github.com/ryanuber/columnize" 18 | ) 19 | 20 | type StatsData struct { 21 | CoreTypeCounter typeCounter 22 | OtherTypeCounter typeCounter 23 | LargestNotes []*items.Note 24 | ItemsOrphanedRefs []ItemOrphanedRefs 25 | LastUpdatedNote *items.Note 26 | NewestNote *items.Note 27 | OldestNote *items.Note 28 | DuplicateNotes []*items.Note 29 | } 30 | 31 | func (i *StatsConfig) GetData() (StatsData, error) { 32 | var err error 33 | 34 | // Always sync first to get latest data 35 | var so cache.SyncOutput 36 | so, err = Sync(cache.SyncInput{ 37 | Session: &i.Session, 38 | }, true) 39 | 40 | // Add delay after sync to prevent consecutive operations from hanging 41 | time.Sleep(1 * time.Second) 42 | 43 | if err != nil { 44 | // Handle sync timeout gracefully - fall back to cached data if available 45 | if strings.Contains(err.Error(), "giving up after") { 46 | fmt.Printf("Warning: Sync timed out, attempting to use cached data\n") 47 | 48 | var allPersistedItems cache.Items 49 | if i.Session.CacheDBPath != "" { 50 | if cacheDB, dbErr := storm.Open(i.Session.CacheDBPath); dbErr == nil { 51 | if dbErr = cacheDB.All(&allPersistedItems); dbErr == nil && len(allPersistedItems) > 0 { 52 | cacheDB.Close() 53 | fmt.Printf("Using cached data (%d items)\n", len(allPersistedItems)) 54 | return i.processStatsFromCacheMetadata(allPersistedItems) 55 | } 56 | cacheDB.Close() 57 | } 58 | } 59 | 60 | // If no cached data available, return empty stats 61 | fmt.Printf("No cached data available, returning empty stats\n") 62 | return StatsData{ 63 | CoreTypeCounter: typeCounter{counts: make(map[string]int64)}, 64 | OtherTypeCounter: typeCounter{counts: make(map[string]int64)}, 65 | }, nil 66 | } else { 67 | return StatsData{}, err 68 | } 69 | } 70 | 71 | // Ensure we close the database when done 72 | defer func() { 73 | if so.DB != nil { 74 | so.DB.Close() 75 | } 76 | }() 77 | 78 | var allPersistedItems cache.Items 79 | if err = so.DB.All(&allPersistedItems); err != nil { 80 | return StatsData{}, err 81 | } 82 | 83 | return i.processStatsFromCache(allPersistedItems) 84 | } 85 | 86 | // processStatsFromCacheMetadata generates stats from cache metadata without decryption 87 | // This is used for large datasets to avoid sync timeout issues 88 | func (i *StatsConfig) processStatsFromCacheMetadata(allPersistedItems cache.Items) (StatsData, error) { 89 | var ctCounter, otCounter typeCounter 90 | ctCounter.counts = make(map[string]int64) 91 | otCounter.counts = make(map[string]int64) 92 | 93 | // Count items by type using cache metadata (no decryption needed) 94 | for _, item := range allPersistedItems { 95 | if item.Deleted { 96 | continue // Skip deleted items 97 | } 98 | 99 | switch item.ContentType { 100 | case common.SNItemTypeNote: 101 | ctCounter.update(common.SNItemTypeNote) 102 | case common.SNItemTypeTag: 103 | ctCounter.update(common.SNItemTypeTag) 104 | default: 105 | otCounter.update(item.ContentType) 106 | } 107 | } 108 | 109 | // Return simplified stats data (no note details since we can't decrypt) 110 | return StatsData{ 111 | CoreTypeCounter: ctCounter, 112 | OtherTypeCounter: otCounter, 113 | LargestNotes: []*items.Note{}, // Empty for metadata-only processing 114 | ItemsOrphanedRefs: []ItemOrphanedRefs{}, // Empty for metadata-only processing 115 | LastUpdatedNote: nil, 116 | NewestNote: nil, 117 | OldestNote: nil, 118 | DuplicateNotes: []*items.Note{}, // Empty for metadata-only processing 119 | }, nil 120 | } 121 | 122 | func (i *StatsConfig) processStatsFromCache(allPersistedItems cache.Items) (StatsData, error) { 123 | gitems, err := allPersistedItems.ToItems(&i.Session) 124 | if err != nil { 125 | return StatsData{}, err 126 | } 127 | 128 | allUUIDs := make([]string, 0, len(allPersistedItems)) 129 | for _, item := range allPersistedItems { 130 | allUUIDs = append(allUUIDs, item.UUID) 131 | } 132 | 133 | ctCounter := typeCounter{counts: make(map[string]int64)} 134 | otCounter := typeCounter{counts: make(map[string]int64)} 135 | var notes items.Items 136 | var itemsOrphanedRefs []ItemOrphanedRefs 137 | var oldestNote, newestNote, lastUpdatedNote *items.Note 138 | var oldestTime, newestTime, lastUpdatedTime time.Time 139 | 140 | for _, item := range gitems { 141 | // Count items by type 142 | if err := i.processItemCount(item, &ctCounter, &otCounter); err != nil { 143 | return StatsData{}, err 144 | } 145 | 146 | // Check for orphaned references 147 | i.checkOrphanedRefs(item, allUUIDs, &itemsOrphanedRefs) 148 | 149 | // Process notes for size and time tracking 150 | if note, isNote := item.(*items.Note); isNote && !i.isNoteInTrash(note) { 151 | stats := ¬eStats{ 152 | notes: ¬es, 153 | oldestNote: &oldestNote, 154 | newestNote: &newestNote, 155 | lastUpdatedNote: &lastUpdatedNote, 156 | oldestTime: &oldestTime, 157 | newestTime: &newestTime, 158 | lastUpdatedTime: &lastUpdatedTime, 159 | } 160 | if err := i.processNoteStats(note, stats); err != nil { 161 | return StatsData{}, err 162 | } 163 | } 164 | } 165 | 166 | // Get largest notes (top 5) 167 | largestNotes := i.getLargestNotes(notes) 168 | 169 | // Get duplicate notes 170 | duplicateNotes := i.findDuplicateNotes(gitems) 171 | 172 | return StatsData{ 173 | CoreTypeCounter: ctCounter, 174 | OtherTypeCounter: otCounter, 175 | LargestNotes: largestNotes, 176 | ItemsOrphanedRefs: itemsOrphanedRefs, 177 | LastUpdatedNote: lastUpdatedNote, 178 | NewestNote: newestNote, 179 | OldestNote: oldestNote, 180 | DuplicateNotes: duplicateNotes, 181 | }, nil 182 | } 183 | 184 | func (i *StatsConfig) processItemCount(item items.Item, ctCounter, otCounter *typeCounter) error { 185 | isTrashedNote := i.isNoteInTrash(item) 186 | 187 | switch { 188 | case isTrashedNote: 189 | ctCounter.update("Notes (In Trash)") 190 | case item.GetContentType() == common.SNItemTypeNote: 191 | ctCounter.update(common.SNItemTypeNote) 192 | case item.GetContentType() == common.SNItemTypeTag: 193 | ctCounter.update(common.SNItemTypeTag) 194 | default: 195 | otCounter.update(item.GetContentType()) 196 | } 197 | return nil 198 | } 199 | 200 | func (i *StatsConfig) isNoteInTrash(item items.Item) bool { 201 | if item.GetContentType() != common.SNItemTypeNote { 202 | return false 203 | } 204 | note, ok := item.(*items.Note) 205 | if !ok { 206 | return false 207 | } 208 | return note.Content.Trashed != nil && *note.Content.Trashed 209 | } 210 | 211 | func (i *StatsConfig) checkOrphanedRefs(item items.Item, allUUIDs []string, itemsOrphanedRefs *[]ItemOrphanedRefs) { 212 | refs := item.GetContent().References() 213 | for _, ref := range refs { 214 | if !StringInSlice(ref.UUID, allUUIDs, false) { 215 | *itemsOrphanedRefs = append(*itemsOrphanedRefs, ItemOrphanedRefs{ 216 | ContentType: item.GetContentType(), 217 | Item: item, 218 | OrphanedRefs: []string{ref.UUID}, 219 | }) 220 | break 221 | } 222 | } 223 | } 224 | 225 | type noteStats struct { 226 | notes *items.Items 227 | oldestNote **items.Note 228 | newestNote **items.Note 229 | lastUpdatedNote **items.Note 230 | oldestTime *time.Time 231 | newestTime *time.Time 232 | lastUpdatedTime *time.Time 233 | } 234 | 235 | func (i *StatsConfig) processNoteStats(note *items.Note, stats *noteStats) error { 236 | cTime, err := time.Parse(timeLayout, note.GetCreatedAt()) 237 | if err != nil { 238 | return err 239 | } 240 | 241 | uTime, err := time.Parse(timeLayout, note.GetUpdatedAt()) 242 | if err != nil { 243 | return err 244 | } 245 | 246 | if stats.oldestTime.IsZero() || cTime.Before(*stats.oldestTime) { 247 | *stats.oldestNote = note 248 | *stats.oldestTime = cTime 249 | } 250 | 251 | if stats.newestTime.IsZero() || cTime.After(*stats.newestTime) { 252 | *stats.newestNote = note 253 | *stats.newestTime = cTime 254 | } 255 | 256 | if stats.lastUpdatedTime.IsZero() || uTime.After(*stats.lastUpdatedTime) { 257 | *stats.lastUpdatedNote = note 258 | *stats.lastUpdatedTime = uTime 259 | } 260 | 261 | if note.GetContentSize() > 0 { 262 | *stats.notes = append(*stats.notes, note) 263 | } 264 | 265 | return nil 266 | } 267 | 268 | func (i *StatsConfig) getLargestNotes(notes items.Items) []*items.Note { 269 | sort.Slice(notes, func(i, j int) bool { 270 | return notes[i].GetContentSize() > notes[j].GetContentSize() 271 | }) 272 | 273 | var largestNotes []*items.Note 274 | maxNotes := 5 275 | if len(notes) < maxNotes { 276 | maxNotes = len(notes) 277 | } 278 | 279 | for i := 0; i < maxNotes; i++ { 280 | largestNotes = append(largestNotes, notes[i].(*items.Note)) 281 | } 282 | 283 | return largestNotes 284 | } 285 | 286 | func (i *StatsConfig) findDuplicateNotes(gitems items.Items) []*items.Note { 287 | var duplicateNotes []*items.Note 288 | 289 | for _, item := range gitems { 290 | if note, isNote := item.(*items.Note); isNote { 291 | // Check if this note has a DuplicateOf field set 292 | if !note.IsDeleted() && note.GetDuplicateOf() != "" { 293 | duplicateNotes = append(duplicateNotes, note) 294 | } 295 | } 296 | } 297 | 298 | return duplicateNotes 299 | } 300 | 301 | type ItemOrphanedRefs struct { 302 | ContentType string 303 | Item items.Item 304 | OrphanedRefs []string 305 | } 306 | 307 | func showNoteHistory(data StatsData) { 308 | table := simpletable.New() 309 | table.Header = &simpletable.Header{ 310 | Cells: []*simpletable.Cell{ 311 | {Align: simpletable.AlignLeft, Text: color.Bold.Sprintf("")}, 312 | {Align: simpletable.AlignLeft, Text: color.Bold.Sprintf("Title")}, 313 | {Align: simpletable.AlignLeft, Text: color.Bold.Sprintf("Time")}, 314 | }, 315 | } 316 | 317 | if data.OldestNote != nil { 318 | data.OldestNote.Content = items.NoteContent{} 319 | } 320 | 321 | if data.NewestNote != nil { 322 | table.Body.Cells = append(table.Body.Cells, []*simpletable.Cell{ 323 | {Text: "Newest"}, 324 | {Text: fmt.Sprintf("%s", data.NewestNote.Content.Title)}, 325 | {Text: fmt.Sprintf("%s", humanize.Time(time.UnixMicro(data.NewestNote.CreatedAtTimestamp)))}, 326 | }) 327 | } 328 | if data.LastUpdatedNote != nil { 329 | table.Body.Cells = append(table.Body.Cells, []*simpletable.Cell{ 330 | {Text: "Most Recently Updated"}, 331 | {Text: fmt.Sprintf("%s", data.LastUpdatedNote.Content.Title)}, 332 | {Text: fmt.Sprintf("%s", humanize.Time(time.UnixMicro(data.LastUpdatedNote.UpdatedAtTimestamp)))}, 333 | }) 334 | } 335 | 336 | color.Bold.Println("Note History") 337 | 338 | table.Println() 339 | } 340 | 341 | func showItemCounts(data StatsData) { 342 | table := simpletable.New() 343 | table.Header = &simpletable.Header{ 344 | Cells: []*simpletable.Cell{ 345 | {Align: simpletable.AlignLeft, Text: color.Bold.Sprintf("Type")}, 346 | {Align: simpletable.AlignLeft, Text: color.Bold.Sprintf("Count")}, 347 | }, 348 | } 349 | 350 | table.Body.Cells = append(table.Body.Cells, []*simpletable.Cell{ 351 | {Text: "Notes"}, 352 | {Text: fmt.Sprintf("%s", humanize.Comma(data.CoreTypeCounter.counts[common.SNItemTypeNote]))}, 353 | }) 354 | table.Body.Cells = append(table.Body.Cells, []*simpletable.Cell{ 355 | {Text: "Tags"}, 356 | {Text: fmt.Sprintf("%s", humanize.Comma(data.CoreTypeCounter.counts[common.SNItemTypeTag]))}, 357 | }) 358 | table.Body.Cells = append(table.Body.Cells, []*simpletable.Cell{ 359 | {Text: "----------------"}, 360 | {Text: "------"}, 361 | }) 362 | table.Body.Cells = append(table.Body.Cells, []*simpletable.Cell{ 363 | {Text: "Notes (In Trash)"}, 364 | {Text: fmt.Sprintf("%s", humanize.Comma(data.CoreTypeCounter.counts["Notes (In Trash)"]))}, 365 | }) 366 | table.Body.Cells = append(table.Body.Cells, []*simpletable.Cell{ 367 | {Text: "----------------"}, 368 | {Text: "------"}, 369 | }) 370 | 371 | var keys []string 372 | 373 | for key := range data.OtherTypeCounter.counts { 374 | keys = append(keys, key) 375 | } 376 | 377 | sort.SliceStable(keys, func(i, j int) bool { 378 | return data.OtherTypeCounter.counts[keys[i]] > data.OtherTypeCounter.counts[keys[j]] 379 | }) 380 | 381 | for _, k := range keys { 382 | table.Body.Cells = append(table.Body.Cells, []*simpletable.Cell{ 383 | {Text: k}, 384 | {Text: fmt.Sprintf("%s", humanize.Comma(data.OtherTypeCounter.counts[k]))}, 385 | }) 386 | } 387 | 388 | color.Bold.Println("Item Counts") 389 | table.Println() 390 | } 391 | 392 | func showLargestNotes(data StatsData) { 393 | table := simpletable.New() 394 | table.Header = &simpletable.Header{ 395 | Cells: []*simpletable.Cell{ 396 | {Align: simpletable.AlignLeft, Text: color.Bold.Sprintf("Size")}, 397 | {Align: simpletable.AlignLeft, Text: color.Bold.Sprintf("Title")}, 398 | }, 399 | } 400 | 401 | for _, note := range data.LargestNotes { 402 | table.Body.Cells = append(table.Body.Cells, []*simpletable.Cell{ 403 | {Text: fmt.Sprintf("%s", humanize.Bytes(uint64(note.GetContentSize())))}, 404 | {Text: fmt.Sprintf("%s", note.Content.Title)}, 405 | }) 406 | } 407 | 408 | color.Bold.Println("Largest Notes") 409 | 410 | table.Println() 411 | } 412 | 413 | func showDuplicateNotes(data StatsData) { 414 | if len(data.DuplicateNotes) == 0 { 415 | return 416 | } 417 | 418 | table := simpletable.New() 419 | table.Header = &simpletable.Header{ 420 | Cells: []*simpletable.Cell{ 421 | {Align: simpletable.AlignLeft, Text: color.Bold.Sprintf("Title")}, 422 | {Align: simpletable.AlignLeft, Text: color.Bold.Sprintf("UUID")}, 423 | {Align: simpletable.AlignLeft, Text: color.Bold.Sprintf("Duplicate Of")}, 424 | }, 425 | } 426 | 427 | for _, note := range data.DuplicateNotes { 428 | title := note.Content.Title 429 | if title == "" { 430 | title = "(Untitled)" 431 | } 432 | table.Body.Cells = append(table.Body.Cells, []*simpletable.Cell{ 433 | {Text: title}, 434 | {Text: note.UUID}, 435 | {Text: note.GetDuplicateOf()}, 436 | }) 437 | } 438 | 439 | color.Bold.Println("Duplicate Notes") 440 | table.Println() 441 | } 442 | 443 | func (i *StatsConfig) Run() error { 444 | data, err := i.GetData() 445 | if err != nil { 446 | return err 447 | } 448 | 449 | showItemCounts(data) 450 | showNoteHistory(data) 451 | showLargestNotes(data) 452 | showDuplicateNotes(data) 453 | 454 | return err 455 | } 456 | 457 | type typeCounter struct { 458 | counts map[string]int64 459 | } 460 | 461 | func (in *typeCounter) update(itemType string) { 462 | var found bool 463 | 464 | for name := range in.counts { 465 | if name == itemType { 466 | found = true 467 | in.counts[name]++ 468 | } 469 | } 470 | 471 | if !found { 472 | in.counts[itemType] = 1 473 | } 474 | } 475 | 476 | func (in *typeCounter) present() { 477 | var lines []string 478 | lines = append(lines, fmt.Sprintf("Notes ^ %d", in.counts[common.SNItemTypeNote])) 479 | lines = append(lines, fmt.Sprintf("Tags ^ %d", in.counts[common.SNItemTypeTag])) 480 | 481 | for name, count := range in.counts { 482 | if name != common.SNItemTypeTag && name != common.SNItemTypeNote && name != "Deleted" { 483 | lines = append(lines, fmt.Sprintf("%s ^ %d", name, count)) 484 | } 485 | } 486 | 487 | config := columnize.DefaultConfig() 488 | config.Delim = "^" 489 | fmt.Println(columnize.Format(lines, config)) 490 | } 491 | -------------------------------------------------------------------------------- /stats_test.go: -------------------------------------------------------------------------------- 1 | package sncli 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | // TestGetDataWithLargeDataset tests GetData with >150 items to reproduce multi-sync issues 12 | func TestGetDataWithLargeDataset(t *testing.T) { 13 | testDelay() 14 | 15 | defer cleanUp(*testSession) 16 | 17 | // Create a large dataset (200 notes) to trigger multiple sync operations 18 | numNotes := 200 19 | textParas := 1 // Minimal content to speed up creation 20 | 21 | t.Logf("Creating %d notes to test GetData with large dataset...", numNotes) 22 | err := createNotes(testSession, numNotes, textParas) 23 | if err != nil { 24 | if strings.Contains(err.Error(), "giving up after") { 25 | t.Logf("Note creation timed out - this is expected with large batches and existing account data") 26 | t.Logf("Proceeding with test using existing data in account") 27 | } else { 28 | require.NoError(t, err) 29 | } 30 | } 31 | 32 | // Add strategic pause after note creation 33 | time.Sleep(2 * time.Second) 34 | 35 | t.Logf("Testing GetData with large dataset (expecting >150 items)...") 36 | 37 | // Create StatsConfig to test GetData 38 | statsConfig := StatsConfig{ 39 | Session: *testSession, 40 | } 41 | 42 | // Test GetData - this should trigger multiple sync operations and reveal the issue 43 | var statsData StatsData 44 | statsData, err = statsConfig.GetData() 45 | if err != nil { 46 | if strings.Contains(err.Error(), "giving up after") { 47 | t.Logf("GetData timed out with large dataset - this confirms the multi-sync issue") 48 | t.Logf("Error: %v", err) 49 | t.Logf("This is the issue we need to fix in cache.Sync reliability") 50 | 51 | // The test should fail here to highlight the issue 52 | t.Fatalf("GetData failed with large dataset: %v", err) 53 | } 54 | require.NoError(t, err) 55 | } 56 | 57 | // Verify we got meaningful stats data 58 | require.NotNil(t, statsData.CoreTypeCounter.counts) 59 | require.NotNil(t, statsData.OtherTypeCounter.counts) 60 | 61 | // Check that we have a reasonable number of notes in the stats 62 | noteCount := statsData.CoreTypeCounter.counts["Note"] 63 | t.Logf("GetData reports %d notes in stats", noteCount) 64 | 65 | // We should have at least some notes (allowing for potential sync timeout with partial data) 66 | require.GreaterOrEqual(t, noteCount, int64(10), "Expected at least 10 notes in stats") 67 | 68 | t.Logf("GetData succeeded with large dataset - no multi-sync issue detected") 69 | } 70 | 71 | // TestGetDataMultipleCalls tests multiple consecutive GetData calls to reproduce hanging 72 | //func TestGetDataMultipleCalls(t *testing.T) { 73 | // testDelay() 74 | // 75 | // defer cleanUp(*testSession) 76 | // 77 | // // Create a moderate dataset to ensure we have some data 78 | // numNotes := 50 79 | // textParas := 1 80 | // 81 | // t.Logf("Creating %d notes for multiple GetData calls test...", numNotes) 82 | // err := createNotes(testSession, numNotes, textParas) 83 | // if err != nil { 84 | // if strings.Contains(err.Error(), "giving up after") { 85 | // t.Logf("Note creation timed out - proceeding with existing account data") 86 | // } else { 87 | // require.NoError(t, err) 88 | // } 89 | // } 90 | // 91 | // // Add strategic pause after note creation 92 | // time.Sleep(1 * time.Second) 93 | // 94 | // statsConfig := StatsConfig{ 95 | // Session: *testSession, 96 | // } 97 | // 98 | // // Test multiple consecutive GetData calls - this often causes the second call to hang 99 | // for i := 1; i <= 3; i++ { 100 | // t.Logf("GetData call #%d...", i) 101 | // 102 | // var statsData StatsData 103 | // statsData, err = statsConfig.GetData() 104 | // if err != nil { 105 | // if strings.Contains(err.Error(), "giving up after") { 106 | // t.Logf("GetData call #%d timed out - this demonstrates the consecutive sync issue", i) 107 | // t.Logf("Error: %v", err) 108 | // 109 | // if i == 1 { 110 | // t.Fatalf("First GetData call failed: %v", err) 111 | // } else { 112 | // t.Fatalf("GetData call #%d failed - consecutive sync issue confirmed: %v", i, err) 113 | // } 114 | // } 115 | // require.NoError(t, err) 116 | // } 117 | // 118 | // // Verify we got valid stats 119 | // require.NotNil(t, statsData.CoreTypeCounter.counts) 120 | // noteCount := statsData.CoreTypeCounter.counts["Note"] 121 | // t.Logf("GetData call #%d succeeded, found %d notes", i, noteCount) 122 | // 123 | // // Add delay between calls to simulate real usage 124 | // if i < 3 { 125 | // time.Sleep(500 * time.Millisecond) 126 | // } 127 | // } 128 | // 129 | // t.Logf("Multiple consecutive GetData calls succeeded - no hanging issue detected") 130 | //} 131 | -------------------------------------------------------------------------------- /sync.go: -------------------------------------------------------------------------------- 1 | package sncli 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/briandowns/spinner" 7 | "github.com/gookit/color" 8 | "github.com/jonhadfield/gosn-v2/cache" 9 | ) 10 | 11 | func Sync(si cache.SyncInput, useStdErr bool) (cache.SyncOutput, error) { 12 | if !si.Debug { 13 | prefix := color.HiWhite.Sprintf("syncing ") 14 | if _, err := os.Stat(si.Session.CacheDBPath); os.IsNotExist(err) { 15 | prefix = color.HiWhite.Sprintf("initializing ") 16 | } 17 | 18 | s := spinner.New(spinner.CharSets[14], spinnerDelay, spinner.WithWriter(os.Stdout)) 19 | if useStdErr { 20 | s = spinner.New(spinner.CharSets[14], spinnerDelay, spinner.WithWriter(os.Stderr)) 21 | } 22 | 23 | s.Prefix = prefix 24 | s.Start() 25 | 26 | so, err := sync(si) 27 | 28 | s.Stop() 29 | 30 | return so, err 31 | } 32 | 33 | return sync(si) 34 | } 35 | 36 | func sync(si cache.SyncInput) (cache.SyncOutput, error) { 37 | return cache.Sync(cache.SyncInput{ 38 | Session: si.Session, 39 | Close: si.Close, 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /tag.go: -------------------------------------------------------------------------------- 1 | package sncli 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/jonhadfield/gosn-v2/cache" 8 | "github.com/jonhadfield/gosn-v2/common" 9 | "github.com/jonhadfield/gosn-v2/items" 10 | ) 11 | 12 | type tagNotesInput struct { 13 | session *cache.Session 14 | matchTitle string 15 | matchText string 16 | matchTags []string 17 | matchNoteUUIDs []string 18 | newTags []string 19 | syncToken string 20 | replace bool 21 | } 22 | 23 | // create tags if they don't exist 24 | // get all notes and tags. 25 | func tagNotes(i tagNotesInput) error { 26 | // create tags if they don't exist 27 | ati := addTagsInput{ 28 | session: i.session, 29 | tagTitles: i.newTags, 30 | replace: i.replace, 31 | } 32 | 33 | if _, err := addTags(ati); err != nil { 34 | return err 35 | } 36 | 37 | // get notes and tags 38 | getNotesFilter := items.Filter{ 39 | Type: common.SNItemTypeNote, 40 | } 41 | getTagsFilter := items.Filter{ 42 | Type: common.SNItemTypeTag, 43 | } 44 | filters := []items.Filter{getNotesFilter, getTagsFilter} 45 | itemFilter := items.ItemFilters{ 46 | MatchAny: true, 47 | Filters: filters, 48 | } 49 | 50 | syncInput := cache.SyncInput{ 51 | Session: i.session, 52 | } 53 | 54 | // get all notes and tags from db 55 | var so cache.SyncOutput 56 | var err error 57 | so, err = Sync(syncInput, true) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | var allPersistedItems cache.Items 63 | if err = so.DB.All(&allPersistedItems); err != nil { 64 | return err 65 | } 66 | 67 | var gItems items.Items 68 | 69 | gItems, err = allPersistedItems.ToItems(i.session) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | gItems.Filter(itemFilter) 75 | 76 | var allTags []*items.Tag 77 | 78 | var allNotes []*items.Note 79 | // create slices of notes and tags 80 | 81 | for _, item := range gItems { 82 | if item.IsDeleted() { 83 | continue 84 | } 85 | 86 | if item.GetContentType() == common.SNItemTypeTag { 87 | allTags = append(allTags, item.(*items.Tag)) 88 | } 89 | 90 | if item.GetContentType() == common.SNItemTypeNote { 91 | allNotes = append(allNotes, item.(*items.Note)) 92 | } 93 | } 94 | 95 | typeUUIDs := make(map[string][]string) 96 | // loop through all notes and create a list of those that 97 | // match the note title or exist in note text 98 | for _, note := range allNotes { 99 | switch { 100 | case StringInSlice(note.UUID, i.matchNoteUUIDs, false): 101 | typeUUIDs[common.SNItemTypeNote] = append(typeUUIDs[common.SNItemTypeNote], note.UUID) 102 | case strings.TrimSpace(i.matchTitle) != "" && strings.Contains(strings.ToLower(note.Content.GetTitle()), strings.ToLower(i.matchTitle)): 103 | typeUUIDs[common.SNItemTypeNote] = append(typeUUIDs[common.SNItemTypeNote], note.UUID) 104 | case strings.TrimSpace(i.matchText) != "" && strings.Contains(strings.ToLower(note.Content.GetText()), strings.ToLower(i.matchText)): 105 | typeUUIDs[common.SNItemTypeNote] = append(typeUUIDs[common.SNItemTypeNote], note.UUID) 106 | } 107 | } 108 | 109 | if len(typeUUIDs[common.SNItemTypeNote]) == 0 { 110 | return errors.New("note not found") 111 | } 112 | 113 | // update existing (and just created) tags to reference matching uuids 114 | // determine which TAGS need updating and create list to sync back to server 115 | var tagsToPush items.Tags 116 | 117 | for _, t := range allTags { 118 | // if tag title is in ones to add then update tag with new references 119 | if StringInSlice(t.Content.GetTitle(), i.newTags, true) { 120 | // does it need updating 121 | updatedTag, changed := upsertTagReferences(*t, typeUUIDs) 122 | if changed { 123 | tagsToPush = append(tagsToPush, updatedTag) 124 | } 125 | } 126 | } 127 | 128 | if len(tagsToPush) > 0 { 129 | if err = cache.SaveTags(so.DB, i.session, tagsToPush, true); err != nil { 130 | return err 131 | } 132 | 133 | pii := cache.SyncInput{ 134 | Session: i.session, 135 | } 136 | 137 | so, err = Sync(pii, true) 138 | if err != nil { 139 | return err 140 | } 141 | 142 | if err = so.DB.Close(); err != nil { 143 | return err 144 | } 145 | 146 | return nil 147 | } 148 | 149 | return nil 150 | } 151 | 152 | func (i *TagItemsConfig) Run() error { 153 | return tagNotes(tagNotesInput{ 154 | matchTitle: i.FindTitle, 155 | matchText: i.FindText, 156 | matchTags: []string{i.FindTag}, 157 | newTags: i.NewTags, 158 | session: i.Session, 159 | }) 160 | } 161 | 162 | func (i *AddTagsInput) Run() (AddTagsOutput, error) { 163 | ati := addTagsInput{ 164 | tagTitles: i.Tags, 165 | parent: i.Parent, 166 | parentUUID: i.ParentUUID, 167 | session: i.Session, 168 | replace: i.Replace, 169 | } 170 | 171 | ato, err := addTags(ati) 172 | if err != nil { 173 | return AddTagsOutput{}, err 174 | } 175 | 176 | output := AddTagsOutput{ 177 | Added: ato.added, 178 | Existing: ato.existing, 179 | } 180 | 181 | return output, nil 182 | } 183 | 184 | func (i *GetItemsConfig) Run() (items.Items, error) { 185 | var so cache.SyncOutput 186 | var err error 187 | 188 | si := cache.SyncInput{ 189 | Session: i.Session, 190 | } 191 | 192 | so, err = Sync(si, true) 193 | if err != nil { 194 | return nil, err 195 | } 196 | 197 | var allPersistedItems cache.Items 198 | 199 | if err = so.DB.All(&allPersistedItems); err != nil { 200 | return nil, err 201 | } 202 | 203 | if err = so.DB.Close(); err != nil { 204 | return nil, err 205 | } 206 | 207 | items, err := allPersistedItems.ToItems(i.Session) 208 | if err != nil { 209 | return nil, err 210 | } 211 | 212 | items.FilterAllTypes(i.Filters) 213 | 214 | return items, nil 215 | } 216 | 217 | func (i *GetTagConfig) Run() (items.Items, error) { 218 | var so cache.SyncOutput 219 | var err error 220 | 221 | si := cache.SyncInput{ 222 | Session: i.Session, 223 | } 224 | 225 | so, err = Sync(si, true) 226 | if err != nil { 227 | return nil, err 228 | } 229 | 230 | var allPersistedItems cache.Items 231 | 232 | if err = so.DB.All(&allPersistedItems); err != nil { 233 | return nil, err 234 | } 235 | 236 | if err = so.DB.Close(); err != nil { 237 | return nil, err 238 | } 239 | 240 | items, err := allPersistedItems.ToItems(i.Session) 241 | if err != nil { 242 | return nil, err 243 | } 244 | 245 | items.Filter(i.Filters) 246 | 247 | return items, nil 248 | } 249 | 250 | func (i *DeleteTagConfig) Run() (int, error) { 251 | noDeleted, err := deleteTags(i.Session, i.TagTitles, i.TagUUIDs) 252 | 253 | return noDeleted, err 254 | } 255 | 256 | func deleteTags(session *cache.Session, tagTitles []string, tagUUIDs []string) (int, error) { 257 | var err error 258 | 259 | deleteTagsFilter := items.Filter{ 260 | Type: common.SNItemTypeTag, 261 | } 262 | filters := []items.Filter{deleteTagsFilter} 263 | deleteFilter := items.ItemFilters{ 264 | MatchAny: true, 265 | Filters: filters, 266 | } 267 | 268 | syncInput := cache.SyncInput{ 269 | Session: session, 270 | } 271 | 272 | // load db 273 | var so cache.SyncOutput 274 | 275 | so, err = Sync(syncInput, true) 276 | if err != nil { 277 | return 0, err 278 | } 279 | 280 | defer func() { 281 | _ = so.DB.Close() 282 | }() 283 | 284 | var tags items.Items 285 | 286 | // get items from db 287 | var allPersistedItems cache.Items 288 | 289 | err = so.DB.All(&allPersistedItems) 290 | if err != nil { 291 | return 0, err 292 | } 293 | 294 | var gItems items.Items 295 | 296 | gItems, err = allPersistedItems.ToItems(session) 297 | if err != nil { 298 | return 0, err 299 | } 300 | 301 | tags = gItems 302 | tags.Filter(deleteFilter) 303 | 304 | if len(tags) == 0 { 305 | return 0, nil 306 | } 307 | 308 | var tagsToDelete items.Items 309 | 310 | for _, tag := range tags { 311 | if tag.IsDeleted() { 312 | continue 313 | } 314 | 315 | var gTag *items.Tag 316 | if tag.GetContentType() == common.SNItemTypeTag { 317 | gTag = tag.(*items.Tag) 318 | } else { 319 | continue 320 | } 321 | 322 | if StringInSlice(gTag.GetUUID(), tagUUIDs, true) || 323 | StringInSlice(gTag.Content.Title, tagTitles, true) { 324 | gTag.Deleted = true 325 | tagsToDelete = append(tagsToDelete, gTag) 326 | } 327 | } 328 | 329 | var eTagsToDelete items.EncryptedItems 330 | 331 | eTagsToDelete, err = tagsToDelete.Encrypt(session.Session, session.DefaultItemsKey) 332 | if err != nil { 333 | return 0, err 334 | } 335 | 336 | if len(eTagsToDelete) == 0 { 337 | return 0, nil 338 | } 339 | 340 | if err = cache.SaveEncryptedItems(so.DB, eTagsToDelete, true); err != nil { 341 | return 0, err 342 | } 343 | 344 | pii := cache.SyncInput{ 345 | Session: session, 346 | Close: true, 347 | } 348 | 349 | _, err = Sync(pii, true) 350 | if err != nil { 351 | return 0, err 352 | } 353 | 354 | noDeleted := len(tagsToDelete) 355 | 356 | return noDeleted, nil 357 | } 358 | 359 | type addTagsInput struct { 360 | session *cache.Session 361 | tagTitles []string 362 | parent string 363 | parentUUID string 364 | replace bool 365 | } 366 | 367 | type addTagsOutput struct { 368 | added []string 369 | existing []string 370 | } 371 | 372 | func addTags(ati addTagsInput) (ato addTagsOutput, err error) { 373 | // get all tags 374 | addTagsFilter := items.Filter{ 375 | Type: common.SNItemTypeTag, 376 | } 377 | 378 | filters := []items.Filter{addTagsFilter} 379 | 380 | addFilter := items.ItemFilters{ 381 | MatchAny: true, 382 | Filters: filters, 383 | } 384 | 385 | putItemsInput := cache.SyncInput{ 386 | Session: ati.session, 387 | } 388 | 389 | var so cache.SyncOutput 390 | 391 | so, err = Sync(putItemsInput, true) 392 | if err != nil { 393 | return ato, err 394 | } 395 | 396 | var allPersistedItems cache.Items 397 | 398 | err = so.DB.All(&allPersistedItems) 399 | if err != nil { 400 | return ato, err 401 | } 402 | 403 | var gItems items.Items 404 | 405 | gItems, err = allPersistedItems.ToItems(ati.session) 406 | if err != nil { 407 | return ato, err 408 | } 409 | 410 | if err = so.DB.Close(); err != nil { 411 | return ato, err 412 | } 413 | 414 | gItems.Filter(addFilter) 415 | 416 | var allTags items.Tags 417 | var parentRef items.ItemReferences 418 | 419 | for _, item := range gItems { 420 | if item.IsDeleted() { 421 | continue 422 | } 423 | 424 | if item.GetContentType() == common.SNItemTypeTag { 425 | tag := item.(*items.Tag) 426 | allTags = append(allTags, *tag) 427 | 428 | if tag.Content.GetTitle() == ati.parent || tag.GetUUID() == ati.parentUUID { 429 | if parentRef != nil { 430 | return ato, errors.New("multiple parent tags found, specify by UUID") 431 | } 432 | itemRef := items.ItemReference{ 433 | UUID: tag.GetUUID(), 434 | ContentType: common.SNItemTypeTag, 435 | ReferenceType: "TagToParentTag", 436 | } 437 | parentRef = items.ItemReferences{itemRef} 438 | } 439 | } 440 | } 441 | 442 | if ati.parent != "" && len(parentRef) == 0 { 443 | return ato, errors.New("parent tag not found by title") 444 | } 445 | 446 | if ati.parentUUID != "" && len(parentRef) == 0 { 447 | return ato, errors.New("parent tag not found by UUID") 448 | } 449 | 450 | var tagsToAdd items.Tags 451 | 452 | for _, tag := range ati.tagTitles { 453 | if tagExists(allTags, tag) { 454 | ato.existing = append(ato.existing, tag) 455 | continue 456 | } 457 | 458 | newTag, _ := items.NewTag(tag, parentRef) 459 | 460 | tagsToAdd = append(tagsToAdd, newTag) 461 | ato.added = append(ato.added, tag) 462 | } 463 | 464 | if len(tagsToAdd) > 0 { 465 | so, err = Sync(putItemsInput, true) 466 | if err != nil { 467 | return ato, err 468 | } 469 | 470 | var eTagsToAdd items.EncryptedItems 471 | 472 | eTagsToAdd, err = tagsToAdd.Encrypt(ati.session.Gosn()) 473 | if err != nil { 474 | return ato, err 475 | } 476 | 477 | err = cache.SaveEncryptedItems(so.DB, eTagsToAdd, true) 478 | if err != nil { 479 | return ato, err 480 | } 481 | 482 | so, err = Sync(putItemsInput, true) 483 | if err != nil { 484 | return ato, err 485 | } 486 | 487 | err = so.DB.Close() 488 | if err != nil { 489 | return ato, err 490 | } 491 | } 492 | 493 | return ato, err 494 | } 495 | 496 | func upsertTagReferences(tag items.Tag, typeUUIDs map[string][]string) (items.Tag, bool) { 497 | // create item reference 498 | var newReferences []items.ItemReference 499 | 500 | var changed bool 501 | 502 | for k, v := range typeUUIDs { 503 | for _, ref := range v { 504 | if !referenceExists(tag, ref) { 505 | newReferences = append(newReferences, items.ItemReference{ 506 | ContentType: k, 507 | UUID: ref, 508 | }) 509 | } 510 | } 511 | } 512 | 513 | if len(newReferences) > 0 { 514 | changed = true 515 | newContent := tag.Content 516 | newContent.UpsertReferences(newReferences) 517 | tag.Content = newContent 518 | } 519 | 520 | return tag, changed 521 | } 522 | 523 | func tagExists(existing []items.Tag, find string) bool { 524 | for _, tag := range existing { 525 | if tag.Content.GetTitle() == find { 526 | return true 527 | } 528 | } 529 | 530 | return false 531 | } 532 | -------------------------------------------------------------------------------- /tag_test.go: -------------------------------------------------------------------------------- 1 | package sncli 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/jonhadfield/gosn-v2/cache" 7 | "github.com/jonhadfield/gosn-v2/common" 8 | "github.com/jonhadfield/gosn-v2/items" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestAddDeleteTagByTitle(t *testing.T) { 13 | defer cleanUp(*testSession) 14 | 15 | testDelay() 16 | 17 | addTagConfig := AddTagsInput{ 18 | Session: testSession, 19 | Tags: []string{"TestTagOne", "TestTagTwo"}, 20 | } 21 | 22 | ato, err := addTagConfig.Run() 23 | require.NoError(t, err) 24 | require.Contains(t, ato.Added, "TestTagOne") 25 | require.Contains(t, ato.Added, "TestTagTwo") 26 | require.Empty(t, ato.Existing) 27 | 28 | deleteTagConfig := DeleteTagConfig{ 29 | Session: testSession, 30 | TagTitles: []string{"TestTagOne", "TestTagTwo"}, 31 | } 32 | 33 | var noDeleted int 34 | noDeleted, err = deleteTagConfig.Run() 35 | require.Equal(t, 2, noDeleted) 36 | require.NoError(t, err) 37 | } 38 | 39 | func TestAddTagWithParent(t *testing.T) { 40 | defer cleanUp(*testSession) 41 | 42 | testDelay() 43 | 44 | addTagConfigParent := AddTagsInput{ 45 | Session: testSession, 46 | Tags: []string{"TestTagParent"}, 47 | } 48 | 49 | ato, err := addTagConfigParent.Run() 50 | require.NoError(t, err) 51 | require.Contains(t, ato.Added, "TestTagParent") 52 | require.Empty(t, ato.Existing) 53 | 54 | addTagConfigChild := AddTagsInput{ 55 | Session: testSession, 56 | Tags: []string{"TestTagChild"}, 57 | Parent: "TestTagParent", 58 | } 59 | 60 | ato, err = addTagConfigChild.Run() 61 | require.NoError(t, err) 62 | require.Contains(t, ato.Added, "TestTagChild") 63 | require.Empty(t, ato.Existing) 64 | 65 | deleteTagConfig := DeleteTagConfig{ 66 | Session: testSession, 67 | TagTitles: []string{"TestTagParent", "TestTagChild"}, 68 | } 69 | 70 | var noDeleted int 71 | noDeleted, err = deleteTagConfig.Run() 72 | require.Equal(t, 2, noDeleted) 73 | require.NoError(t, err) 74 | } 75 | 76 | func TestGetTag(t *testing.T) { 77 | testDelay() 78 | 79 | defer cleanUp(*testSession) 80 | 81 | testTagTitles := []string{"TestTagOne", "TestTagTwo"} 82 | addTagInput := AddTagsInput{ 83 | Session: testSession, 84 | Tags: testTagTitles, 85 | } 86 | 87 | ato, err := addTagInput.Run() 88 | require.NoError(t, err) 89 | require.NoError(t, err) 90 | require.Contains(t, ato.Added, "TestTagOne") 91 | require.Contains(t, ato.Added, "TestTagTwo") 92 | require.Empty(t, ato.Existing) 93 | 94 | // create filters 95 | getTagFilters := items.ItemFilters{ 96 | MatchAny: true, 97 | } 98 | 99 | for _, testTagTitle := range testTagTitles { 100 | getTagFilters.Filters = append(getTagFilters.Filters, items.Filter{ 101 | Key: "Title", 102 | Value: testTagTitle, 103 | Type: common.SNItemTypeTag, 104 | Comparison: "==", 105 | }) 106 | } 107 | 108 | getTagConfig := GetTagConfig{ 109 | Session: testSession, 110 | Filters: getTagFilters, 111 | } 112 | 113 | var output items.Items 114 | output, err = getTagConfig.Run() 115 | require.NoError(t, err) 116 | require.EqualValues(t, len(output), 2, "expected two items but got: %+v", output) 117 | } 118 | 119 | func _addNotes(session cache.Session, i map[string]string) error { 120 | for k, v := range i { 121 | addNoteConfig := AddNoteInput{ 122 | Session: &session, 123 | Title: k, 124 | Text: v, 125 | } 126 | 127 | err := addNoteConfig.Run() 128 | if err != nil { 129 | return err 130 | } 131 | } 132 | 133 | return nil 134 | } 135 | 136 | func _deleteNotesByTitle(session cache.Session, input map[string]string) (noDeleted int, err error) { 137 | var noteTitles []string 138 | for k := range input { 139 | noteTitles = append(noteTitles, k) 140 | } 141 | deleteNoteConfig := DeleteNoteConfig{ 142 | Session: &session, 143 | NoteTitles: noteTitles, 144 | } 145 | 146 | noDeleted, err = deleteNoteConfig.Run() 147 | if err != nil { 148 | return noDeleted, err 149 | } 150 | 151 | return noDeleted, deleteNoteConfig.Session.CacheDB.Close() 152 | } 153 | 154 | func _deleteTagsByTitle(session cache.Session, input []string) (noDeleted int, err error) { 155 | deleteTagConfig := DeleteTagConfig{ 156 | Session: &session, 157 | TagTitles: input, 158 | } 159 | 160 | return deleteTagConfig.Run() 161 | } 162 | 163 | func TestTaggingOfNotes(t *testing.T) { 164 | testDelay() 165 | defer cleanUp(*testSession) 166 | 167 | // create four notes 168 | notes := map[string]string{ 169 | "noteOneTitle": "noteOneText example", 170 | "noteTwoTitle": "noteTwoText", 171 | "noteThreeTitle": "noteThreeText", 172 | "noteFourTitle": "noteFourText example", 173 | } 174 | 175 | err := _addNotes(*testSession, notes) 176 | require.NoError(t, err) 177 | // tag new notes with 'testTag' 178 | tags := []string{"testTag"} 179 | tni := TagItemsConfig{ 180 | Session: testSession, 181 | FindText: "example", 182 | NewTags: tags, 183 | } 184 | err = tni.Run() 185 | require.NoError(t, err) 186 | 187 | filterNotesByTagName := items.Filter{ 188 | Type: common.SNItemTypeNote, 189 | Key: "TagTitle", 190 | Comparison: "==", 191 | Value: "testTag", 192 | } 193 | itemFilters := items.ItemFilters{ 194 | Filters: []items.Filter{filterNotesByTagName}, 195 | MatchAny: true, 196 | } 197 | gnc := GetNoteConfig{ 198 | Session: testSession, 199 | Filters: itemFilters, 200 | } 201 | 202 | var retNotes items.Items 203 | retNotes, err = gnc.Run() 204 | require.NoError(t, err) 205 | 206 | if len(retNotes) != 2 { 207 | t.Errorf("expected two notes but got: %d", len(retNotes)) 208 | } 209 | require.NoError(t, testSession.CacheDB.Close()) 210 | 211 | nd, err := _deleteNotesByTitle(*testSession, notes) 212 | require.NoError(t, err) 213 | require.Equal(t, 4, nd) 214 | 215 | var deletedTags int 216 | deletedTags, err = _deleteTagsByTitle(*testSession, tags) 217 | require.NoError(t, err) 218 | require.Equal(t, 1, deletedTags) 219 | } 220 | 221 | func TestGetTagsByTitleAndUUID(t *testing.T) { 222 | addTagConfig := AddTagsInput{ 223 | Session: testSession, 224 | Tags: []string{"TestTagOne", "TestTagTwo"}, 225 | } 226 | 227 | ato, err := addTagConfig.Run() 228 | require.NoError(t, err) 229 | require.Contains(t, ato.Added, "TestTagOne") 230 | require.Contains(t, ato.Added, "TestTagTwo") 231 | require.Empty(t, ato.Existing) 232 | } 233 | -------------------------------------------------------------------------------- /tasks_small_test.go: -------------------------------------------------------------------------------- 1 | package sncli 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/jonhadfield/gosn-v2/items" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestBoolToText(t *testing.T) { 11 | require.Equal(t, "yes", boolToText(true, "yes", "no")) 12 | require.Equal(t, "no", boolToText(false, "yes", "no")) 13 | } 14 | 15 | func TestOutputChars(t *testing.T) { 16 | require.Equal(t, "hello", outputChars("hello", 10)) 17 | long := "abcdefghijklmnop" 18 | require.Equal(t, "abcdefghij...", outputChars(long, 10)) 19 | } 20 | 21 | func TestTaskListsConflictedWarning(t *testing.T) { 22 | tasks := []items.Tasklist{{}, {}} 23 | require.Contains(t, taskListsConflictedWarning(tasks), "2 conflicted versions") 24 | require.Equal(t, "-", taskListsConflictedWarning(nil)) 25 | } 26 | 27 | func TestFilterTasks(t *testing.T) { 28 | tasks := items.Tasks{ 29 | {Title: "done", Completed: true}, 30 | {Title: "todo", Completed: false}, 31 | } 32 | completed := filterTasks(tasks, true) 33 | require.Len(t, completed, 1) 34 | require.Equal(t, "done", completed[0].Title) 35 | incomplete := filterTasks(tasks, false) 36 | require.Len(t, incomplete, 1) 37 | require.Equal(t, "todo", incomplete[0].Title) 38 | } 39 | 40 | func TestFilterAdvancedChecklistTasks(t *testing.T) { 41 | tasks := items.AdvancedChecklistTasks{ 42 | {Description: "d1", Completed: true}, 43 | {Description: "d2", Completed: false}, 44 | } 45 | completed := filterAdvancedChecklistTasks(tasks, true) 46 | require.Len(t, completed, 1) 47 | require.Equal(t, "d1", completed[0].Description) 48 | incomplete := filterAdvancedChecklistTasks(tasks, false) 49 | require.Len(t, incomplete, 1) 50 | require.Equal(t, "d2", incomplete[0].Description) 51 | } 52 | --------------------------------------------------------------------------------