diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..53c37a1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +dist \ No newline at end of file diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..8392d15 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake \ No newline at end of file diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index bf337b6..8ffeee6 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -3,15 +3,13 @@ name: Build, Test and Lint Armaria on: pull_request: types: [opened, synchronize] - branches: - - main permissions: + contents: read pull-requests: read jobs: lint: - name: Lint PR title runs-on: ubuntu-latest steps: - uses: amannn/action-semantic-pull-request@v5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..583d8f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.db +*.db-shm +*.db-wal +cli/cli +.direnv +armaria +dist \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..6a7e7b5 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,33 @@ +linters: + disable-all: true + enable: + - govet + - errcheck + - gosimple + - ineffassign + - staticcheck + - asciicheck + - containedctx + - contextcheck + - errchkjson + - errorlint + - forcetypeassert + - gocheckcompilerdirectives + - gochecknoinits + - gochecknoglobals + - gofmt + - gosec + - makezero + - musttag + - nilerr + - nilnil + - predeclared + - reassign + - sqlclosecheck + - unconvert + - unparam + - wastedassign +run: + skip-files: + - lib/go_sqlite3_migrate.go + - test/armaria_test.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..5fa2a39 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,93 @@ +env: + - CGO_ENABLED=1 + +builds: + - id: armaria-darwin-amd64 + binary: armaria + main: ./cli + goarch: + - amd64 + goos: + - darwin + env: + - CC=o64-clang + - CXX=o64-clang++ + flags: + - -trimpath + - -tags=fts5 + + - id: armaria-darwin-arm64 + binary: armria + main: ./cli + goarch: + - arm64 + goos: + - darwin + env: + - CC=oa64-clang + - CXX=oa64-clang++ + flags: + - -trimpath + - -tags=fts5 + + - id: armaria-linux-amd64 + binary: armaria + main: ./cli + env: + - CC=x86_64-linux-gnu-gcc + - CXX=x86_64-linux-gnu-g++ + goarch: + - amd64 + goos: + - linux + flags: + - -trimpath + - -tags=fts5 + ldflags: + - -extldflags "-lc -lrt -lpthread --static" + - -s + - -w + + - id: armaria-windows-amd64 + binary: armaria + main: ./cli + goarch: + - amd64 + goos: + - windows + env: + - CC=x86_64-w64-mingw32-gcc + - CXX=x86_64-w64-mingw32-g++ + flags: + - -trimpath + - -buildmode=exe + - -tags=fts5 + +universal_binaries: + - id: armaria-darwin-universal + ids: + - armaria-darwin-amd64 + - armaria-darwin-arm64 + replace: true + name_template: "armaria" + +archives: + - id: w/version + builds: + - armaria-darwin-universal + - armaria-linux-amd64 + - armaria-windows-amd64 + name_template: "armaria_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + wrap_in_directory: false + format: zip + files: + - none* + +checksum: + name_template: 'checksums.txt' + +snapshot: + name_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9e3aa6b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Jonathan Hope + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.org b/README.org new file mode 100644 index 0000000..270eea3 --- /dev/null +++ b/README.org @@ -0,0 +1,67 @@ +* Armaria + +#+BEGIN_QUOTE +Armaria are a kind of closed, labeled cupboards that were used for book storage in ancient times up till the middle ages. +#+END_QUOTE + +*NOTE: This software is still in progress and should be considered pre alpha* + +Armaria is a new way to manage your bookmarks. + +As it stands bookmarks are stored either in the cloud, in browser specific formats, or both. It doesn't have to be this way. Armaria stores bookmarks in a SQLite database. This means your bookmarks are local and shared across all clients including browsers. + +* Supported Platforms + + - Windows x64 + - Linux x64 + - Mac x64/arm64 + +* Features + + - URL, Name, and Description fields + - Nested folder structure + - Tags + - Full text search + - CLI + +* Schema + +The ERD for the bookmarks database is as follows: + +#+begin_src mermaid :file "bookmarks-db.svg" :pupeteer-config-file "~/.emacs.d/pupeteer-config.json" :mermaid-config-file "~/.emacs.d/mermaid-config.json" :background-color "transparent" +erDiagram + bookmarks ||--|{ bookmarks_tags: "" + tags ||--|{ bookmarks_tags: "" + bookmarks o|--o{ bookmarks: "" + + bookmarks { + text id + text parent_id + integer is_folder + text name + text url + text description + text modified + } + + tags { + text tag + text modified + } + + bookmarks_tags { + text bookmark_id + text tag + text modified + } +#+end_src + +#+RESULTS: +[[file:bookmarks-db.svg]] + + +* FAQ + +** How do I back up my bookmarks? + +You can use whatever you are already using to backup your files to the cloud. diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..402d281 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,56 @@ +version: '3' + +tasks: + lint-lib: + dir: lib + cmds: + - golangci-lint run --config ../.golangci.yml --timeout 3m + lint-cli: + dir: cli + cmds: + - golangci-lint run --config ../.golangci.yml --timeout 3m + lint-bdd: + dir: bdd + cmds: + - golangci-lint run --config ../.golangci.yml --timeout 3m + lint: + cmds: + - task: lint-lib + - task: lint-cli + - task: lint-bdd + test-bdd: + dir: bdd + cmds: + - go test --tags "fts5" + test-lib: + dir: lib + cmds: + - go test --tags "fts5" + test: + cmds: + - task: test-bdd + - task: test-lib + build: + dir: cli + cmds: + - go build --tags "fts5" -ldflags "-s -w" + - cp cli ../armaria + migrate-up: + cmds: + - goose -dir ./lib/migrations sqlite3 ./bookmarks.db up + migrate-down: + cmds: + - goose -dir ./lib/migrations sqlite3 ./bookmarks.db down + clean: + cmds: + - rm -f armaria + - rm -f cli/cli + - find . -name "*.db" -type f -delete + - find . -name "*.db-shm" -type f -delete + - find . -name "*.db-wal" -type f -delete + release: + cmds: + - docker run -v $(pwd):/src -w /src -e GITHUB_TOKEN=$GITHUB_TOKEN -i goreleaser/goreleaser-cross:v1.20 release + release-snapshot: + cmds: + - docker run -v $(pwd):/src -w /src -i goreleaser/goreleaser-cross:v1.20 release --snapshot diff --git a/bdd/armaria_test.go b/bdd/armaria_test.go new file mode 100644 index 0000000..71b9eac --- /dev/null +++ b/bdd/armaria_test.go @@ -0,0 +1,45 @@ +package test + +import ( + "flag" + "os" + "testing" + + "github.com/cucumber/godog" + "github.com/cucumber/godog/colors" +) + +// This integrates godog with go test. +// It was taken from the godog docs. + +//nolint:gochecknoglobals +var opts = godog.Options{ + Output: colors.Colored(os.Stdout), + Format: "progress", + Concurrency: 4, +} + +//nolint:gochecknoinits +func init() { + godog.BindFlags("godog.", flag.CommandLine, &opts) +} + +func TestFeatures(t *testing.T) { + o := opts + o.TestingT = t + + status := godog.TestSuite{ + Name: "aramaria", + Options: &o, + TestSuiteInitializer: InitializeTestSuite, + ScenarioInitializer: InitializeScenario, + }.Run() + + if status == 2 { + t.SkipNow() + } + + if status != 0 { + t.Fatalf("zero status code expected, %d received", status) + } +} diff --git a/bdd/features/cli_add_book.feature b/bdd/features/cli_add_book.feature new file mode 100644 index 0000000..e713d2f --- /dev/null +++ b/bdd/features/cli_add_book.feature @@ -0,0 +1,171 @@ +Feature: Add Bookmarks with CLI + + The Armaria CLI can be used to add a new bookmark. + + @cli @add_book + Scenario: Can add a bookmark + When I run it with the following args: + """ + add book https://jho.pe + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + + @cli @add_book + Scenario: Can add a boookmark to a folder + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_id} | NULL | true | blogs | NULL | NULL | | + When I run it with the following args: + """ + add book https://jho.pe --folder [parent_id] + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + | [parent_id] | NULL | true | blogs | NULL | NULL | | + | {id} | [parent_id] | false | https://jho.pe | https://jho.pe | NULL | | + + @cli @add_book + Scenario: Can add a bookmark with a name + When I run it with the following args: + """ + add book https://jho.pe --name "The Flat Field" + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | The Flat Field | https://jho.pe | NULL | | + + @cli @add_book + Scenario: Can add a bookmark with a description + When I run it with the following args: + """ + add book https://jho.pe --description "The blog of Jonathan Hope." + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | The blog of Jonathan Hope. | | + + @cli @add_book + Scenario: Can add a bookmark with tags + When I run it with the following args: + """ + add book https://jho.pe --tag "blog" --tag "programming" + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | blog, programming | + And the folllowing tags exist: + | tag | + | blog | + | programming | + + @cli @add_book + Scenario: Folder must exist + When I run it with the following args: + """ + add book https://jho.pe --folder test + """ + Then the following error is returned: + """ + Folder not found + """ + + @cli @add_book + Scenario: URL must be at least 1 char + When I run it with the following args: + """ + add book "" + """ + Then the following error is returned: + """ + URL too short + """ + + @cli @add_book + Scenario: URL must be at most 2048 chars + When I run it with the following args: + """ + add book %repeat:x:2049% + """ + Then the following error is returned: + """ + URL too long + """ + + @cli @add_book + Scenario: Name must be at most 2048 chars + When I run it with the following args: + """ + add book https://jho.pe --name %repeat:x:2049% + """ + Then the following error is returned: + """ + Name too long + """ + + @cli @add_book + Scenario: Description must be at leat 1 char + When I run it with the following args: + """ + add book https://jho.pe --description "" + """ + Then the following error is returned: + """ + Description too short + """ + + @cli @add_book + Scenario: Description must be at most 4096 chars + When I run it with the following args: + """ + add book https://jho.pe --description %repeat:x:4097% + """ + Then the following error is returned: + """ + Description too long + """ + + @cli @add_book + Scenario: Tag must be at most 128 chars + When I run it with the following args: + """ + add book https://jho.pe --tag %repeat:x:129% + """ + Then the following error is returned: + """ + Tag too long + """ + + @cli @add_book + Scenario: Tags must be unique + When I run it with the following args: + """ + add book https://jho.pe --tag blog --tag blog + """ + Then the following error is returned: + """ + Tags must be unique + """ + + @cli @add_book + Scenario: Can have at most 24 tags + When I run it with the following args: + """ + add book https://jho.pe %repeat: --tag "[uuid]":25% + """ + Then the following error is returned: + """ + Too many tags applied to bookmark + """ + + @cli @add_book + Scenario: Tags must be in the char range [A-Z][a-z][0-9]-_ + When I run it with the following args: + """ + add book https://jho.pe --tag ? + """ + Then the following error is returned: + """ + Tag has invalid chars + """ diff --git a/bdd/features/cli_add_folder.feature b/bdd/features/cli_add_folder.feature new file mode 100644 index 0000000..83a7d88 --- /dev/null +++ b/bdd/features/cli_add_folder.feature @@ -0,0 +1,60 @@ +Feature: Add Folders with CLI + + The Armaria CLI can be used to add a new folder. + + @cli @add_folder + Scenario: Can add a folder + When I run it with the following args: + """ + add folder blogs + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | true | blogs | NULL | NULL | | + + @cli @add_folder + Scenario: Can add a folder to a folder + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_id} | NULL | true | tech | NULL | NULL | | + When I run it with the following args: + """ + add folder blogs --folder [parent_id] + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + | [parent_id] | NULL | true | tech | NULL | NULL | | + | {id} | [parent_id] | true | blogs | NULL | NULL | | + + @cli @add_folder + Scenario: Folder must exist + When I run it with the following args: + """ + add folder blogs --folder test + """ + Then the following error is returned: + """ + Folder not found + """ + + @cli @add_folder + Scenario: Name must be at least 1 char + When I run it with the following args: + """ + add folder "" + """ + Then the following error is returned: + """ + Name too short + """ + + @cli @add_folder + Scenario: Name must be at most 2048 chars + When I run it with the following args: + """ + add folder %repeat:x:2049% + """ + Then the following error is returned: + """ + Name too long + """ diff --git a/bdd/features/cli_add_tag.feature b/bdd/features/cli_add_tag.feature new file mode 100644 index 0000000..e7d3b84 --- /dev/null +++ b/bdd/features/cli_add_tag.feature @@ -0,0 +1,101 @@ +Feature: Add Tags to Bookmark with CLI + + The Armaria CLI can be used to add tags to an existing bookmark. + + @cli @add_tags + Scenario: Can add tags + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + add tag [id] --tag blog --tag programming + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + | [id] | NULL | false | https://jho.pe | https://jho.pe | NULL | blog, programming | + And the folllowing tags exist: + | tag | + | blog | + | programming | + + @cli @add_tags + Scenario: Bookmark must exist + When I run it with the following args: + """ + add tag test --tag blog + """ + Then the following error is returned: + """ + Bookmark not found + """ + + @cli @add_tags + Scenario: Tags must be in the char range [A-Z][a-z][0-9]-_ + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + add tag [id] --tag ? + """ + Then the following error is returned: + """ + Tag has invalid chars + """ + + @cli @add_tags + Scenario: Tag must be at most 128 chars + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + add tag [id] --tag %repeat:x:129% + """ + Then the following error is returned: + """ + Tag too long + """ + + @cli @add_tags + Scenario: Can have at most 24 tags + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | blog | + When I run it with the following args: + """ + add tag [id] %repeat: --tag "[uuid]":24% + """ + Then the following error is returned: + """ + Too many tags applied to bookmark + """ + + @cli @add_tags + Scenario: Tags must be unique + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + add tag [id] --tag blog --tag blog + """ + Then the following error is returned: + """ + Tags must be unique + """ + + @cli @add_tags + Scenario: Cannot add same tag again + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | blog | + When I run it with the following args: + """ + add tag [id] --tag blog + """ + Then the following error is returned: + """ + Tags must be unique + """ diff --git a/bdd/features/cli_list_all.feature b/bdd/features/cli_list_all.feature new file mode 100644 index 0000000..98d28fa --- /dev/null +++ b/bdd/features/cli_list_all.feature @@ -0,0 +1,140 @@ +Feature: List All with CLI + + The Armaria CLI can be used to list bookmarks and folders. + + @cli @list_all + Scenario: Can list bookmarks/folders + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_1_id} | NULL | true | blogs | NULL | NULL | | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + list all + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [parent_1_id] | NULL | true | blogs | NULL | NULL | | + | [id] | NULL | false | https://jho.pe | https://jho.pe | NULL | | + + @cli @list_all + Scenario: Can limit listed bookmarks/folders + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_1_id} | NULL | true | blogs | NULL | NULL | | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + list all --first 1 + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [parent_1_id] | NULL | true | blogs | NULL | NULL | | + + @cli @list_all + Scenario: Can order bookmarks/folders by name ascending + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_1_id} | NULL | true | blogs | NULL | NULL | | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + list all --order name --dir asc + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [parent_1_id] | NULL | true | blogs | NULL | NULL | | + | [id] | NULL | false | https://jho.pe | https://jho.pe | NULL | | + + @cli @list_all + Scenario: Can order bookmarks/folders by name descending + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_1_id} | NULL | true | blogs | NULL | NULL | | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + list all --order name --dir desc + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [id] | NULL | false | https://jho.pe | https://jho.pe | NULL | | + | [parent_1_id] | NULL | true | blogs | NULL | NULL | | + + @cli @list_all + Scenario: Can list bookmarks/folders after bookmark/folder + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_1_id} | NULL | true | blogs | NULL | NULL | | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + list all --order name --dir asc --after [parent_1_id] + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [id] | NULL | false | https://jho.pe | https://jho.pe | NULL | | + + @cli @list_all + Scenario: Can list bookmarks/folders in a parent folder + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_1_id} | NULL | true | blogs | NULL | NULL | | + | {id} | [parent_1_id] | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + list all --folder [parent_1_id] + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [id] | [parent_1_id] | false | https://jho.pe | https://jho.pe | NULL | | + + @cli @list_all + Scenario: Can search bookmarks/folders + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_1_id} | NULL | true | blogs | | NULL | | + | {id} | [parent_1_id] | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + list all --query jho + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [id] | [parent_1_id] | false | https://jho.pe | https://jho.pe | NULL | | + + @cli @list_all + Scenario: Can filter bookmarks/folders by tag + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_1_id} | NULL | true | blogs | NULL | NULL | | + | {id} | [parent_1_id] | false | https://jho.pe | https://jho.pe | NULL | blog | + When I run it with the following args: + """ + list all --tag blog + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [id] | [parent_1_id] | false | https://jho.pe | https://jho.pe | NULL | blog | + + @cli @list_all + Scenario: First must be greater than zero + When I run it with the following args: + """ + list all --first 0 + """ + Then the following error is returned: + """ + First too small + """ + + @cli @list_all + Scenario: Query must be at leat three chars + When I run it with the following args: + """ + list all --query a + """ + Then the following error is returned: + """ + Query too short + """ diff --git a/bdd/features/cli_list_books.feature b/bdd/features/cli_list_books.feature new file mode 100644 index 0000000..5393a98 --- /dev/null +++ b/bdd/features/cli_list_books.feature @@ -0,0 +1,148 @@ +Feature: List Books with CLI + + The Armaria CLI can be used to list books. + + @cli @list_books + Scenario: Can list bookmarks + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_id} | NULL | true | blogs | NULL | NULL | | + | {id_1} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + | {id_2} | NULL | false | https://armaria.net | https://armaria.net | NULL | | + When I run it with the following args: + """ + list books + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [id_1] | NULL | false | https://jho.pe | https://jho.pe | NULL | | + | [id_2] | NULL | false | https://armaria.net | https://armaria.net | NULL | | + + @cli @list_books + Scenario: Can limit listed bookmarks + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_id} | NULL | true | blogs | NULL | NULL | | + | {id_1} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + | {id_2} | NULL | false | https://armaria.net | https://armaria.net | NULL | | + When I run it with the following args: + """ + list books --first 1 + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [id_1] | NULL | false | https://jho.pe | https://jho.pe | NULL | | + + @cli @list_books + Scenario: Can order bookmarks by name ascending + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_id} | NULL | true | blogs | NULL | NULL | | + | {id_1} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + | {id_2} | NULL | false | https://armaria.net | https://armaria.net | NULL | | + When I run it with the following args: + """ + list books --order name --dir asc + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [id_2] | NULL | false | https://armaria.net | https://armaria.net | NULL | | + | [id_1] | NULL | false | https://jho.pe | https://jho.pe | NULL | | + + @cli @list_books + Scenario: Can order bookmarks by name descending + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_id} | NULL | true | blogs | NULL | NULL | | + | {id_1} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + | {id_2} | NULL | false | https://armaria.net | https://armaria.net | NULL | | + When I run it with the following args: + """ + list books --order name --dir desc + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [id_1] | NULL | false | https://jho.pe | https://jho.pe | NULL | | + | [id_2] | NULL | false | https://armaria.net | https://armaria.net | NULL | | + + @cli @list_books + Scenario: Can list bookmarks after bookmark + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_id} | NULL | true | blogs | NULL | NULL | | + | {id_1} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + | {id_2} | NULL | false | https://armaria.net | https://armaria.net | NULL | | + When I run it with the following args: + """ + list books --order name --dir asc --after [id_2] + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [id_1] | NULL | false | https://jho.pe | https://jho.pe | NULL | | + + @cli @list_books + Scenario: Can list bookmarks in a parent folder + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_id} | NULL | true | blogs | NULL | NULL | | + | {id_1} | [parent_id] | false | https://jho.pe | https://jho.pe | NULL | | + | {id_2} | NULL | false | https://armaria.net | https://armaria.net | NULL | | + When I run it with the following args: + """ + list books --folder [parent_id] + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [id_1] | [parent_id] | false | https://jho.pe | https://jho.pe | NULL | | + + @cli @list_books + Scenario: Can search bookmarks + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_id} | NULL | true | blogs | NULL | NULL | | + | {id_1} | [parent_id] | false | https://jho.pe | https://jho.pe | NULL | | + | {id_2} | NULL | false | https://armaria.net | https://armaria.net | NULL | | + When I run it with the following args: + """ + list books --query jho + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [id_1] | [parent_id] | false | https://jho.pe | https://jho.pe | NULL | | + + @cli @list_books + Scenario: Can filter bookmarks by tag + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_id} | NULL | true | blogs | NULL | NULL | | + | {id_1} | NULL | false | https://jho.pe | https://jho.pe | NULL | blog | + | {id_2} | NULL | false | https://armaria.net | https://armaria.net | NULL | | + When I run it with the following args: + """ + list books --tag blog + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [id_1] | NULL | false | https://jho.pe | https://jho.pe | NULL | blog | + + @cli @list_books + Scenario: First must be greater than zero + When I run it with the following args: + """ + list books --first 0 + """ + Then the following error is returned: + """ + First too small + """ + + @cli @list_books + Scenario: Query must be at leat three chars + When I run it with the following args: + """ + list books --query a + """ + Then the following error is returned: + """ + Query too short + """ diff --git a/bdd/features/cli_list_folders.feature b/bdd/features/cli_list_folders.feature new file mode 100644 index 0000000..d710d36 --- /dev/null +++ b/bdd/features/cli_list_folders.feature @@ -0,0 +1,133 @@ +Feature: List Folder with CLI + + The Armaria CLI can be used to list folders. + + @cli @list_folders + Scenario: Can list folders + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_1_id} | NULL | true | blogs | NULLL | NULL | | + | {parent_2_id} | NULL | true | tech | NULL | NULL | | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + list folders + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [parent_1_id] | NULL | true | blogs | NULL | NULL | | + | [parent_2_id] | NULL | true | tech | NULL | NULL | | + + @cli @list_folders + Scenario: Can limit listed folders + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_1_id} | NULL | true | blogs | NULL | NULL | | + | {parent_2_id} | NULL | true | tech | NULL | NULL | | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + list folders --first 1 + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [parent_1_id] | NULL | true | blogs | NULL | NULL | | + + @cli @list_folders + Scenario: Can order folders by name ascending + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_1_id} | NULL | true | blogs | NULL | NULL | | + | {parent_2_id} | NULL | true | tech | NULL | NULL | | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + list folders --order name --dir asc + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [parent_1_id] | NULL | true | blogs | NULL | NULL | | + | [parent_2_id] | NULL | true | tech | NULL | NULL | | + + @cli @list_folders + Scenario: Can order folders by name descending + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_1_id} | NULL | true | blogs | NULL | NULL | | + | {parent_2_id} | NULL | true | tech | NULL | NULL | | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + list folders --order name --dir desc + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [parent_2_id] | NULL | true | tech | NULL | NULL | | + | [parent_1_id] | NULL | true | blogs | NULL | NULL | | + + @cli @list_folders + Scenario: Can list folders after folder + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_1_id} | NULL | true | blogs | NULL | NULL | | + | {parent_2_id} | NULL | true | tech | NULL | NULL | | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + list folders --order name --dir asc --after [parent_1_id] + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [parent_2_id] | NULL | true | tech | NULL | NULL | | + + @cli @list_folders + Scenario: Can list folders in a parent folder + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_1_id} | NULL | true | blogs | NULL | NULL | | + | {parent_2_id} | [parent_1_id] | true | tech | NULL | NULL | | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + list folders --folder [parent_1_id] + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [parent_2_id] | [parent_1_id] | true | tech | NULL | NULL | | + + @cli @list_folders + Scenario: Can search folders + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_1_id} | NULL | true | blogs | NULL | NULL | | + | {parent_2_id} | [parent_1_id] | true | tech | NULL | NULL | | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + list folders --query ech + """ + Then the folllowing books are returned: + | id | parent_id | is_folder | name | url | description | tags | + | [parent_2_id] | [parent_1_id] | true | tech | NULL | NULL | | + + @cli @list_folders + Scenario: First must be greater than zero + When I run it with the following args: + """ + list folders --first 0 + """ + Then the following error is returned: + """ + First too small + """ + + @cli @list_folders + Scenario: Query must be at leat three chars + When I run it with the following args: + """ + list folders --query a + """ + Then the following error is returned: + """ + Query too short + """ diff --git a/bdd/features/cli_list_tags.feature b/bdd/features/cli_list_tags.feature new file mode 100644 index 0000000..8bb86e8 --- /dev/null +++ b/bdd/features/cli_list_tags.feature @@ -0,0 +1,106 @@ +Feature: List Tags with CLI + + The Armaria CLI can be used to list tags. + + @cli @list_tags + Scenario: Can list tags + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | blog, programming | + When I run it with the following args: + """ + list tags + """ + Then the folllowing tags are returned: + | tag | + | blog | + | programming | + + @cli @list_tags + Scenario: Can order tags by tag ascending + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | blog, programming | + When I run it with the following args: + """ + list tags --dir asc + """ + Then the folllowing tags are returned: + | tag | + | blog | + | programming | + + @cli @list_tags + Scenario: Can order tags by tag descending + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | blog, programming | + When I run it with the following args: + """ + list tags --dir desc + """ + Then the folllowing tags are returned: + | tag | + | programming | + | blog | + + @cli @list_tags + Scenario: Can limit listed tags + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | blog, programming | + When I run it with the following args: + """ + list tags --first 1 + """ + Then the folllowing tags are returned: + | tag | + | blog | + + @cli @list_tags + Scenario: Can list tags after a tag + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | blog, programming | + When I run it with the following args: + """ + list tags --after blog + """ + Then the folllowing tags are returned: + | tag | + | programming | + + @cli @list_tags + Scenario: Can search tags + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | blog, programming | + When I run it with the following args: + """ + list tags --query gram + """ + Then the folllowing tags are returned: + | tag | + | programming | + + @cli @list_tags + Scenario: First must be greater than zero + When I run it with the following args: + """ + list tags --first 0 + """ + Then the following error is returned: + """ + First too small + """ + + @cli @list_tags + Scenario: Query must be at leat three chars + When I run it with the following args: + """ + list tags --query a + """ + Then the following error is returned: + """ + Query too short + """ diff --git a/bdd/features/cli_remove_book.feature b/bdd/features/cli_remove_book.feature new file mode 100644 index 0000000..a6138c1 --- /dev/null +++ b/bdd/features/cli_remove_book.feature @@ -0,0 +1,28 @@ +Feature: Remove Books with CLI + + The Armaria CLI can be used to remove an existing book. + + @cli @remove_book + Scenario: Can remove bookmark + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | blog, programming | + When I run it with the following args: + """ + remove book [id] + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | + And the folllowing tags exist: + | tag | + + @cli @remove_book + Scenario: Bookmark must exist + When I run it with the following args: + """ + remove book test + """ + Then the following error is returned: + """ + Bookmark not found + """ diff --git a/bdd/features/cli_remove_folder.feature b/bdd/features/cli_remove_folder.feature new file mode 100644 index 0000000..d79da5c --- /dev/null +++ b/bdd/features/cli_remove_folder.feature @@ -0,0 +1,30 @@ +Feature: Remove Folders with CLI + + The Armaria CLI can be used to remove an existing folder. + + @cli @remove_folder + Scenario: Can remove folder + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_1_id} | NULL | true | tech | | NULL | | + | {parent_2_id} | [parent_1_id] | true | blogs | | NULL | | + | {id} | [parent_2_id] | false | https://jho.pe | https://jho.pe | NULL | blog, programming | + When I run it with the following args: + """ + remove folder [parent_1_id] + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + And the folllowing tags exist: + | tag | + + @cli @remove_folder + Scenario: Folder must exist + When I run it with the following args: + """ + remove folder test + """ + Then the following error is returned: + """ + Folder not found + """ diff --git a/bdd/features/cli_remove_tag.feature b/bdd/features/cli_remove_tag.feature new file mode 100644 index 0000000..d78c05f --- /dev/null +++ b/bdd/features/cli_remove_tag.feature @@ -0,0 +1,43 @@ +Feature: Remove Tags from Bookmark with CLI + + The Armaria CLI can be used to remove tags from an existing bookmark. + + @cli @remove_tags + Scenario: Can remove tags + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | blog, programming | + When I run it with the following args: + """ + remove tag [id] --tag blog --tag programming + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + | [id] | NULL | false | https://jho.pe | https://jho.pe | NULL | | + And the folllowing tags exist: + | tag | + + @cli @remove_tags + Scenario: Bookmark must exist + When I run it with the following args: + """ + remove tag test --tag blog + """ + Then the following error is returned: + """ + Bookmark not found + """ + + @cli @remove_tags + Scenario: Tag must exist + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + remove tag [id] --tag blog + """ + Then the following error is returned: + """ + Tag not found + """ diff --git a/bdd/features/cli_update_book.feature b/bdd/features/cli_update_book.feature new file mode 100644 index 0000000..4cd52bb --- /dev/null +++ b/bdd/features/cli_update_book.feature @@ -0,0 +1,237 @@ +Feature: Update Bookmarks with CLI + + The Armaria CLI can be used to update an existing bookmark. + + @cli @update_book + Scenario: Can update bookmark URL + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + update book [id] --url https://theflatfield.net + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + | [id] | NULL | false | https://jho.pe | https://theflatfield.net | NULL | | + + @cli @update_book + Scenario: Can update bookmark name + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + update book [id] --name "The Flat Field" + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + | [id] | NULL | false | The Flat Field | https://jho.pe | NULL | | + + @cli @update_book + Scenario: Can update bookmark description + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + update book [id] --description "The blog of Jonathan Hope." + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + | [id] | NULL | false | https://jho.pe | https://jho.pe | The blog of Jonathan Hope. | | + + @cli @update_book + Scenario: Can remove bookmark description + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | The blog of Jonathan Hope. | | + When I run it with the following args: + """ + update book [id] --no-description + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + | [id] | NULL | false | https://jho.pe | https://jho.pe | NULL | | + + @cli @update_book + Scenario: Can move bookmark + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_id} | NULL | true | blogs | NULL | NULL | | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + update book [id] --folder [parent_id] + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + | [parent_id] | NULL | true | blogs | NULL | NULL | | + | [id] | [parent_id] | false | https://jho.pe | https://jho.pe | NULL | | + + @cli @update_book + Scenario: Can remove parent folder + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_id} | NULL | true | blogs | NULL | NULL | | + | {id} | [parent_id] | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + update book [id] --no-folder + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + | [parent_id] | NULL | true | blogs | NULL | NULL | | + | [id] | NULL | false | https://jho.pe | https://jho.pe | NULL | | + + @cli @update_book + Scenario: Bookmark must exist + When I run it with the following args: + """ + update book test --name "The Flat Field" + """ + Then the following error is returned: + """ + Bookmark not found + """ + + @cli @update_book + Scenario: At least one update is required + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + update book [id] + """ + Then the following error is returned: + """ + At least one update is required + """ + + @cli @update_book + Scenario: Name must be at least 1 char + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + update book [id] --name "" + """ + Then the following error is returned: + """ + Name too short + """ + + @cli @update_book + Scenario: Name must be at most 2048 chars + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + update book [id] --name %repeat:x:2049% + """ + Then the following error is returned: + """ + Name too long + """ + + @cli @update_book + Scenario: URL must be at least 1 char + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + update book [id] --url "" + """ + Then the following error is returned: + """ + URL too short + """ + + @cli @update_book + Scenario: URL must be at most 2048 chars + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + update book [id] --url %repeat:x:2049% + """ + Then the following error is returned: + """ + URL too long + """ + + @cli @update_book + Scenario: Description must be at least 1 char + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + update book [id] --description "" + """ + Then the following error is returned: + """ + Description too short + """ + + @cli @update_book + Scenario: Description must be at most 4096 chars + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + update book [id] --description %repeat:x:4097% + """ + Then the following error is returned: + """ + Description too long + """ + + @cli @update_book + Scenario: Cannot update and remove description at the same time + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + update book [id] --description "The blog of Jonathan Hope." --no-description + """ + Then the following error is returned: + """ + Arguments description and no-description are mutually exclusive + """ + + @cli @update_book + Scenario: Cannot move and remove folder at the same time + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_id} | NULL | true | tech | NULL | NULL | | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + update book [id] --folder [parent_id] --no-folder + """ + Then the following error is returned: + """ + Arguments folder and no-folder are mutually exclusive + """ + + @cli @update_book + Scenario: Parent folder must exist + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | false | https://jho.pe | https://jho.pe | NULL | | + When I run it with the following args: + """ + update book [id] --folder test + """ + Then the following error is returned: + """ + Folder not found + """ diff --git a/bdd/features/cli_update_folder.feature b/bdd/features/cli_update_folder.feature new file mode 100644 index 0000000..99a3dd7 --- /dev/null +++ b/bdd/features/cli_update_folder.feature @@ -0,0 +1,157 @@ +Feature: Update Folders with CLI + + The Armaria CLI can be used to update an existing folder. + + @cli @update_folder + Scenario: Can update folder name + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | true | blogs | NULL | NULL | | + When I run it with the following args: + """ + update folder [id] --name new + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + | [id] | NULL | true | new | NULL | NULL | | + + @cli @update_folder + Scenario: Can move folder + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_id} | NULL | true | tech | NULL | NULL | | + | {id} | NULL | true | blogs | NULL | NULL | | + When I run it with the following args: + """ + update folder [id] --folder [parent_id] + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + | [parent_id] | NULL | true | tech | NULL | NULL | | + | [id] | [parent_id] | true | blogs | NULL | NULL | | + + @cli @update_folder + Scenario: Can move folder + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_id} | NULL | true | tech | NULL | NULL | | + | {id} | NULL | true | blogs | NULL | NULL | | + When I run it with the following args: + """ + update folder [id] --folder [parent_id] + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + | [parent_id] | NULL | true | tech | NULL | NULL | | + | [id] | [parent_id] | true | blogs | NULL | NULL | | + + @cli @update_folder + Scenario: Can remove parent folder + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_id} | NULL | true | tech | NULL | NULL | | + | {id} | [parent_id] | true | blogs | NULL | NULL | | + When I run it with the following args: + """ + update folder [id] --no-folder + """ + Then the following bookmarks/folders exist: + | id | parent_id | is_folder | name | url | description | tags | + | [parent_id] | NULL | true | tech | NULL | NULL | | + | [id] | NULL | true | blogs | NULL | NULL | | + + @cli @update_folder + Scenario: Name must be at most 2048 chars + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | true | blogs | NULL | NULL | | + When I run it with the following args: + """ + update folder [id] --name %repeat:x:2049% + """ + Then the following error is returned: + """ + Name too long + """ + + @cli @update_folder + Scenario: Name must be at least 1 char + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | true | blogs | NULL | NULL | | + When I run it with the following args: + """ + update folder [id] --name "" + """ + Then the following error is returned: + """ + Name too short + """ + + @cli @update_folder + Scenario: Name must be at most 2048 chars + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | true | blogs | NULL | NULL | | + When I run it with the following args: + """ + update folder [id] --name %repeat:x:2049% + """ + Then the following error is returned: + """ + Name too long + """ + + @cli @update_folder + Scenario: Parent folder must exist + Given the DB already has the following entries: + | itd | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | true | blogs | NULL | NULL | | + When I run it with the following args: + """ + update folder [id] --folder test + """ + Then the following error is returned: + """ + Folder not found + """ + + @cli @update_folder + Scenario: Cannot move and remove folder at the same time + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {parent_id} | NULL | true | tech | NULL | NULL | | + | {id} | NULL | true | blogs | NULL | NULL | | + When I run it with the following args: + """ + update folder [id] --folder [parent_id] --no-folder + """ + Then the following error is returned: + """ + Arguments folder and no-folder are mutually exclusive + """ + + @cli @update_folder + Scenario: Folder must exist + When I run it with the following args: + """ + update folder test --name "new" + """ + Then the following error is returned: + """ + Folder not found + """ + + @cli @update_folder + Scenario: At least one update is required + Given the DB already has the following entries: + | id | parent_id | is_folder | name | url | description | tags | + | {id} | NULL | true | blogs | NULL | NULL | | + When I run it with the following args: + """ + update folder [id] + """ + Then the following error is returned: + """ + At least one update is required + """ diff --git a/bdd/go.mod b/bdd/go.mod new file mode 100644 index 0000000..30cd934 --- /dev/null +++ b/bdd/go.mod @@ -0,0 +1,16 @@ +module github.com/jonathanhope/armaria/test + +go 1.20 + +require ( + github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect + github.com/cucumber/godog v0.13.0 // indirect + github.com/cucumber/messages/go/v21 v21.0.1 // indirect + github.com/gofrs/uuid v4.3.1+incompatible // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-memdb v1.3.4 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/bdd/go.sum b/bdd/go.sum new file mode 100644 index 0000000..993ce7e --- /dev/null +++ b/bdd/go.sum @@ -0,0 +1,64 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI= +github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0= +github.com/cucumber/godog v0.13.0 h1:KvX9kNWmAJwp882HmObGOyBbNUP5SXQ+SDLNajsuV7A= +github.com/cucumber/godog v0.13.0/go.mod h1:FX3rzIDybWABU4kuIXLZ/qtqEe1Ac5RdXmqvACJOces= +github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI= +github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s= +github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay93k7Rn3Dee7iyPJjs= +github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/friendsofgo/errors v0.9.2 h1:X6NYxef4efCBdwI7BgS820zFaN7Cphrmb+Pljdzjtgk= +github.com/friendsofgo/errors v0.9.2/go.mod h1:yCvFW5AkDIL9qn7suHVLiI/gH228n7PC4Pn44IGoTOI= +github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= +github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-memdb v1.3.4 h1:XSL3NR682X/cVk2IeV0d70N4DZ9ljI885xAEU8IoK3c= +github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sanity-io/litter v1.5.5 h1:iE+sBxPBzoK6uaEP5Lt3fHNgpKcHXc/A2HGETy0uJQo= +github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/volatiletech/inflect v0.0.1 h1:2a6FcMQyhmPZcLa+uet3VJ8gLn/9svWhJxJYwvE8KsU= +github.com/volatiletech/inflect v0.0.1/go.mod h1:IBti31tG6phkHitLlr5j7shC5SOo//x0AjDzaJU1PLA= +github.com/volatiletech/null v8.0.0+incompatible h1:7wP8m5d/gZ6kW/9GnrLtMCRre2dlEnaQ9Km5OXlK4zg= +github.com/volatiletech/null v8.0.0+incompatible/go.mod h1:0wD98JzdqB+rLyZ70fN05VDbXbafIb0KU0MdVhCzmOQ= +github.com/volatiletech/sqlboiler v3.7.1+incompatible h1:dm9/NjDskQVwAarmpeZ2UqLn1NKE8M3WHSHBS4jw2x8= +github.com/volatiletech/sqlboiler v3.7.1+incompatible/go.mod h1:jLfDkkHWPbS2cWRLkyC20vQWaIQsASEY7gM7zSo11Yw= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/bdd/steps.go b/bdd/steps.go new file mode 100644 index 0000000..c0e1684 --- /dev/null +++ b/bdd/steps.go @@ -0,0 +1,298 @@ +package test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "github.com/cucumber/godog" + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/jonathanhope/armaria/cli/cmd" + "github.com/jonathanhope/armaria/lib" +) + +// Context keys. + +type dbContextKey struct{} +type outputContextKey struct{} +type variablesContextKey struct{} + +// InitializeTestSuite wires up events. +func InitializeTestSuite(ctx *godog.TestSuiteContext) { + ctx.ScenarioContext().Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) { + // Each scenario run gets its own DB. + db := fmt.Sprintf("%s.db", uuid.New()) + ctx = context.WithValue(ctx, dbContextKey{}, db) + + ctx = context.WithValue(ctx, variablesContextKey{}, make(map[string]interface{})) + + return ctx, nil + }) + + ctx.ScenarioContext().After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) { + if err != nil { + return nil, err + } + + db, ok := ctx.Value(dbContextKey{}).(string) + if !ok { + return ctx, errors.New("Missing DB name") + } + + // When the test scenario is over delete the per scenario DB. + + if _, err := os.Stat(db); err == nil { + if err = os.Remove(db); err != nil { + os.Remove(db) + } + } + + shm := fmt.Sprintf("%s-shm", db) + if _, err := os.Stat(shm); err == nil { + if err = os.Remove(shm); err != nil { + os.Remove(db) + } + } + + wal := fmt.Sprintf("%s-wal", db) + if _, err := os.Stat(wal); err == nil { + if err = os.Remove(wal); err != nil { + os.Remove(db) + } + } + + return ctx, nil + }) +} + +// InitializeScenario wires up the steps. +func InitializeScenario(ctx *godog.ScenarioContext) { + ctx.Step(`^the DB already has the following entries:$`, theDBAlreadyHasTheFollowingEntries) + ctx.Step(`^I run it with the following args:$`, iRunItWithTheFollowingArgs) + ctx.Step(`^the following bookmarks\/folders exist:$`, theFollowingBookmarksFoldersExist) + ctx.Step(`^the folllowing tags exist:$`, theFollowingTagsExist) + ctx.Step(`^the following error is returned:$`, theFollowingErrorIsReturned) + ctx.Step(`^the folllowing tags are returned:$`, theFolllowingTagsAreReturned) + ctx.Step(`^the folllowing books are returned:$`, theFolllowingBooksAreReturned) +} + +// theDBAlreadyHasTheFollowingEntries inserts data from a cucumber table into the bookmarks database. +func theDBAlreadyHasTheFollowingEntries(ctx context.Context, table *godog.Table) (context.Context, error) { + vars, ok := ctx.Value(variablesContextKey{}).(map[string]interface{}) + if !ok { + return ctx, errors.New("Missing variables") + } + + db, ok := ctx.Value(dbContextKey{}).(string) + if !ok { + return ctx, errors.New("Missing DB name") + } + + for _, row := range table.Rows[1:] { + err := insert(insertArgs{ + db: db, + vars: vars, + id: row.Cells[0].Value, + parentID: row.Cells[1].Value, + isFolder: row.Cells[2].Value, + name: row.Cells[3].Value, + url: row.Cells[4].Value, + description: row.Cells[5].Value, + tags: row.Cells[6].Value, + }) + if err != nil { + return ctx, err + } + } + + return ctx, nil +} + +// iRunItWithTheFollowingArgs runs the CLI with the provided args. +func iRunItWithTheFollowingArgs(ctx context.Context, args *godog.DocString) (context.Context, error) { + db, ok := ctx.Value(dbContextKey{}).(string) + if !ok { + return ctx, errors.New("Missing DB name") + } + + vars, ok := ctx.Value(variablesContextKey{}).(map[string]interface{}) + if !ok { + return ctx, errors.New("Missing variables") + } + + cmd, err := processCommand(vars, fmt.Sprintf("%s --db %s --formatter json", args.Content, db)) + if err != nil { + return ctx, err + } + + // Store the output for future use. + output, err := invokeCli(fmt.Sprintf("%s --db %s --formatter json", cmd, db)) + return context.WithValue(ctx, outputContextKey{}, output), err +} + +// theFollowingBookmarksFoldersExist compares the JSON output of the list all command with a cucumber results table. +func theFollowingBookmarksFoldersExist(ctx context.Context, table *godog.Table) error { + vars, ok := ctx.Value(variablesContextKey{}).(map[string]interface{}) + if !ok { + return errors.New("Missing variables") + } + + db, ok := ctx.Value(dbContextKey{}).(string) + if !ok { + return errors.New("Missing DB name") + } + + output, ok := ctx.Value(outputContextKey{}).(string) + if !ok { + return errors.New("Missing DB name") + } + + if strings.HasPrefix(output, `"`) { + fmt.Printf("Unexpected Error: %s\n", output) + } + + output, err := invokeCli(fmt.Sprintf("list all --db %s --formatter json", db)) + if err != nil { + return err + } + + var actual []cmd.BookDTO + if err := json.Unmarshal([]byte(output), &actual); err != nil { + return err + } + + expected, err := tableToBooks(vars, actual, table) + if err != nil { + return err + } + + // The cucumber tables don't have parent name so it gets nulled out. + for i := range actual { + actual[i].ParentName = lib.NullStringFromPtr(nil) + } + + markDirty(expected, actual) + + diff := cmp.Diff(expected, actual) + if diff != "" { + return fmt.Errorf("Expected and actual books different:\n%s", diff) + } + + return nil +} + +// theFollowingTagsExist compares the JSON output of the list all command with a cucumber results table. +func theFollowingTagsExist(ctx context.Context, table *godog.Table) error { + db, ok := ctx.Value(dbContextKey{}).(string) + if !ok { + return errors.New("Missing DB name") + } + + output, err := invokeCli(fmt.Sprintf("list tags --db %s --formatter json", db)) + if err != nil { + return err + } + + var actual []string + if err := json.Unmarshal([]byte(output), &actual); err != nil { + return err + } + + expected := tableToTags(table) + if err != nil { + return err + } + + if len(expected) == 0 && len(actual) == 0 { + return nil + } + + diff := cmp.Diff(expected, actual) + if diff != "" { + return fmt.Errorf("Expected and actual tags different:\n%s", diff) + } + + return nil +} + +// theFollowingErrorIsReturned compares the JSON output of the CLI run with a cucumber error string. +func theFollowingErrorIsReturned(ctx context.Context, message *godog.DocString) error { + output, ok := ctx.Value(outputContextKey{}).(string) + if !ok { + return errors.New("Missing output") + } + + expected := strings.TrimSpace(fmt.Sprintf("\"%s\"", message.Content)) + + actual := strings.TrimSpace(output) + + diff := cmp.Diff(expected, actual) + if diff != "" { + return fmt.Errorf("Expected and actual errors different:\n%s", diff) + } + + return nil +} + +// theFolllowingTagsAreReturned compares the JSON output of the CLI with a set of tags. +func theFolllowingTagsAreReturned(ctx context.Context, table *godog.Table) error { + output, ok := ctx.Value(outputContextKey{}).(string) + if !ok { + return errors.New("Missing output") + } + + var expected []string + for _, row := range table.Rows[1:] { + expected = append(expected, row.Cells[0].Value) + } + + var actual []string + err := json.Unmarshal([]byte(output), &actual) + if err != nil { + return err + } + + diff := cmp.Diff(expected, actual) + if diff != "" { + return fmt.Errorf("Expected and actual tags different:\n%s", diff) + } + + return nil +} + +// theFolllowingBooksAreReturned compares the JSON output of the CLI with a set of bookmarks/folders. +func theFolllowingBooksAreReturned(ctx context.Context, table *godog.Table) error { + output, ok := ctx.Value(outputContextKey{}).(string) + if !ok { + return errors.New("Missing output") + } + + vars, ok := ctx.Value(variablesContextKey{}).(map[string]interface{}) + if !ok { + return errors.New("Missing variables") + } + + var actual []cmd.BookDTO + err := json.Unmarshal([]byte(output), &actual) + if err != nil { + return err + } + + expected, err := tableToBooks(vars, actual, table) + if err != nil { + return err + } + + markDirty(expected, actual) + + diff := cmp.Diff(expected, actual) + if diff != "" { + return fmt.Errorf("Expected and actual books different:\n%s", diff) + } + + return nil +} diff --git a/bdd/test_helpers.go b/bdd/test_helpers.go new file mode 100644 index 0000000..cea8359 --- /dev/null +++ b/bdd/test_helpers.go @@ -0,0 +1,493 @@ +package test + +import ( + "bytes" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/alecthomas/kong" + "github.com/cucumber/godog" + "github.com/google/shlex" + "github.com/google/uuid" + "github.com/jonathanhope/armaria/cli/cmd" + "github.com/jonathanhope/armaria/lib" +) + +// invokeCli runs the Armaria CLI with the provided args. +func invokeCli(args string) (string, error) { + // All of this is to invoke a Kong CLI app directly in code. + // A buffer is used to intercept output. + + rootCmd := cmd.RootCmdFactory() + w := bytes.NewBuffer(nil) + + options := []kong.Option{ + kong.Name("test"), + kong.Exit(func(int) { + panic(true) + }), + kong.Writers(w, w), + } + + parser, err := kong.New(&rootCmd, options...) + if err != nil { + return "", err + } + + // We need to use this parser to take shell quoting into account. + tokens, err := shlex.Split(args) + if err != nil { + return "", err + } + + ctx, err := parser.Parse(tokens) + if err != nil { + return "", err + } + + err = ctx.Run(&cmd.Context{ + DB: rootCmd.DB, + Formatter: rootCmd.Formatter, + Writer: w, + ReturnCode: noop}) + if err != nil { + return "", err + } + + return w.String(), nil +} + +// insertArgs is params to insert a bookmark or folder. +type insertArgs struct { + db string + vars map[string]interface{} + id string + parentID string + isFolder string + name string + url string + description string + tags string +} + +// insert inserts a bookmark or folder directly into the bookmarks DB. +func insert(args insertArgs) error { + _, storeId, idKey, err := handleString(args.vars, args.id) + if err != nil { + return err + } + + parentID, storeParentID, parentIdKey, err := handleNullString(args.vars, args.parentID) + if err != nil { + return err + } + + isFolder, storeIsFolder, isFolderKey, err := handleBool(args.vars, args.isFolder) + if err != nil { + return err + } + + name, storeName, nameKey, err := handleString(args.vars, args.name) + if err != nil { + return err + } + + URL, storeURL, URLKey, err := handleNullString(args.vars, args.url) + if err != nil { + return err + } + + desc, storeDesc, descKey, err := handleNullString(args.vars, args.description) + if err != nil { + return err + } + + tags, storeTags, tagsKey, err := handleCompoundString(args.vars, args.tags) + if err != nil { + return err + } + + var book lib.Book + if !isFolder { + options := lib.DefaultAddBookOptions() + options.WithDB(args.db) + if parentID.Valid { + options.WithParentID(parentID.String) + } + if name != "" { + options.WithName(name) + } + if desc.Valid { + options.WithDescription(desc.String) + } + if tags != nil { + options.WithTags(tags) + } + + book, err = lib.AddBook(URL.String, options) + if err != nil { + return err + } + } else { + options := lib.DefaultAddFolderOptions() + options.WithDB(args.db) + if parentID.Valid { + options.WithParentID(parentID.String) + } + + book, err = lib.AddFolder(name, options) + if err != nil { + return err + } + } + + // Store any variables that were denoted with {...} in the cucumber table. + + if storeId { + args.vars[idKey] = book.ID + } + + if storeParentID { + args.vars[parentIdKey] = lib.NullStringFromPtr(book.ParentID) + } + + if storeIsFolder { + args.vars[isFolderKey] = book.IsFolder + } + + if storeName { + args.vars[nameKey] = book.Name + } + + if storeURL { + args.vars[URLKey] = lib.NullStringFromPtr(book.URL) + } + + if storeDesc { + args.vars[descKey] = lib.NullStringFromPtr(book.Description) + } + + if storeTags { + args.vars[tagsKey] = book.Tags + } + + return nil +} + +// tableToBooks converts a cucumber table to a collection of bookmarks/folders. +func tableToBooks(vars map[string]interface{}, actual []cmd.BookDTO, table *godog.Table) ([]cmd.BookDTO, error) { + books := make([]cmd.BookDTO, 0) + for _, row := range table.Rows[1:] { + id, storeId, _, err := handleString(vars, row.Cells[0].Value) + if err != nil { + return nil, err + } + + parentId, storeParentId, _, err := handleNullString(vars, row.Cells[1].Value) + if err != nil { + return nil, err + } + + isFolder, storeIsFolder, _, err := handleBool(vars, row.Cells[2].Value) + if err != nil { + return nil, err + } + + name, storeName, _, err := handleString(vars, row.Cells[3].Value) + if err != nil { + return nil, err + } + + URL, storeURL, _, err := handleNullString(vars, row.Cells[4].Value) + if err != nil { + return nil, err + } + + desc, storeDesc, _, err := handleNullString(vars, row.Cells[5].Value) + if err != nil { + return nil, err + } + + tags, storeTags, _, err := handleCompoundString(vars, row.Cells[6].Value) + if err != nil { + return nil, err + } + + // The {...} is used as a wildcard in the result table. + // Using the last inserted result was the best way I could think of to model this. + + last := actual[len(actual)-1] + + if storeId { + id = last.ID + } + + if storeParentId { + parentId = last.ParentID + } + + if storeIsFolder { + isFolder = last.IsFolder + } + + if storeName { + name = last.Name + } + + if storeURL { + URL = last.URL + } + + if storeDesc { + desc = last.Description + } + + if storeTags { + tags = last.Tags + } + + book := cmd.BookDTO{ + ID: id, + ParentID: parentId, + IsFolder: isFolder, + Name: name, + URL: URL, + Description: desc, + Tags: tags, + } + + books = append(books, book) + } + + return books, nil +} + +// tableToTags converts a cucumber table to a collection of tags. +func tableToTags(table *godog.Table) []string { + tags := make([]string, 0) + for _, row := range table.Rows[1:] { + tags = append(tags, row.Cells[0].Value) + } + + return tags +} + +// handleString handles a string value in a cucumber table. +// Returns a flag to denote if result should be stored as a variable. +// Will retreive a variable if the [...] denotation is used. +func handleString(vars map[string]interface{}, val string) (res string, store bool, key string, err error) { + if isVariableSet(val) { + store = true + key = stripVariable(val) + return + } else if isVariableGet(val) { + val, ok := vars[stripVariable(val)] + if !ok { + err = errors.New("Missing variable") + return + } + + if res, ok = val.(string); !ok { + err = fmt.Errorf("Could not convert variable to string; type was %T", val) + return + } + } else { + res = val + } + + return +} + +// handleNullString handles a nullable string value in a cucumber table. +// Returns a flag to denote if result should be stored as a variable. +// Will retreive a variable if the [...] denotation is used. +// Handles NULL literal correctly. +func handleNullString(vars map[string]interface{}, val string) (res lib.NullString, store bool, key string, err error) { + if isVariableSet(val) { + store = true + key = stripVariable(val) + return + } else if isVariableGet(val) { + val, ok := vars[stripVariable(val)] + if !ok { + err = errors.New("Missing variable") + return + } + + if converted, ok := val.(lib.NullString); ok { + res = converted + return + } else if converted, ok := val.(string); ok { + res = lib.NullStringFrom(converted) + } else { + err = fmt.Errorf("Could not convert variable to string; type was %T", val) + } + } else if val != "NULL" { + res = lib.NullStringFrom(val) + } + + return +} + +// handleBool handles a bool value in a cucumber table. +// Returns a flag to denote if result should be stored as a variable. +// Will retreive a variable if the [...] denotation is used. +func handleBool(vars map[string]interface{}, val string) (res bool, store bool, key string, err error) { + if isVariableSet(val) { + store = true + key = stripVariable(val) + return + } else if isVariableGet(val) { + val, ok := vars[stripVariable(val)] + if !ok { + err = errors.New("Missing variable") + return + } + + if res, ok = val.(bool); !ok { + err = fmt.Errorf("Could not convert variable to bool; type was %T", val) + return + } + } else { + parsed, parseErr := strconv.ParseBool(val) + if parseErr != nil { + err = parseErr + return + } + + res = parsed + } + + return +} + +// handleCompoundString handles a comma separated string value. +// Returns a flag to denote if result should be stored as a variable. +// Will retreive a variable if the [...] denotation is used. +func handleCompoundString(vars map[string]interface{}, val string) (res []string, store bool, key string, err error) { + if val == "" { + res = make([]string, 0) + return + } + + if isVariableSet(val) { + store = true + key = stripVariable(val) + return + } else if isVariableGet(val) { + val, ok := vars[stripVariable(val)] + if !ok { + err = errors.New("Missing variable") + return + } + + if res, ok = val.([]string); !ok { + err = fmt.Errorf("Could not convert variable to []string; type was %T", val) + return + } + } else { + res = strings.Split(val, ", ") + } + + return +} + +// isVariableGet returns true if the cell is a request to get a variable. +func isVariableGet(value string) bool { + return strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") +} + +// isVariableSet returns true if the cell is a request to store a variable. +func isVariableSet(value string) bool { + return strings.HasPrefix(value, "{") && strings.HasSuffix(value, "}") +} + +// stripVariable returns the variable name if it is wrapped in {...} or [...]. +func stripVariable(value string) string { + return value[1 : len(value)-1] +} + +// variableToString converts a stored variable to string. +func variableToString(value interface{}) (string, error) { + if v, ok := value.(string); ok { + return v, nil + } else if v, ok := value.(bool); ok { + return strconv.FormatBool(v), nil + } else if v, ok := value.(lib.NullString); ok { + if v.Valid { + return v.String, nil + } else { + return "NULL", nil + } + } else { + return "", errors.New("Could not convert variable to string") + } +} + +// noop does nothing. +// We don't want to actually os.exit here since that would exit the test. +func noop(code int) {} + +// markDirty marks every field as dirty. +// The dirty field is not relevant to tests. +func markDirty(expected []cmd.BookDTO, actual []cmd.BookDTO) { + for i := range expected { + expected[i].Description.Dirty = false + expected[i].ParentID.Dirty = false + expected[i].ParentName.Dirty = false + expected[i].URL.Dirty = false + expected[i].ParentName = lib.NullString{} + } + + for i := range actual { + actual[i].Description.Dirty = false + actual[i].ParentID.Dirty = false + actual[i].ParentName.Dirty = false + actual[i].URL.Dirty = false + actual[i].ParentName = lib.NullString{} + } +} + +// processCommand swaps out special tokens in the CLI command. +func processCommand(vars map[string]interface{}, cmd string) (string, error) { + // Replace any variable templates in the CLI args. + // The per scenario DB name is passed in. + // The JSON formatter is used so the output is machine readable. + for k := range vars { + str, err := variableToString(vars[k]) + if err != nil { + return "", err + } + + cmd = strings.Replace(cmd, fmt.Sprintf("[%s]", k), str, -1) + } + + // Syntax like %repeat:{str}:{num}% can be used to generate strings of arbitrary length. + // This is useful for bounds checking while keeping the Gherkin clean. + var compRegEx = regexp.MustCompile(`\%repeat:(.+?):(\d+?)\%`) + match := compRegEx.FindStringSubmatch(cmd) + if len(match) == 3 { + substr := match[1] + length, err := strconv.Atoi(match[2]) + if err != nil { + return "", err + } + + var str = "" + for i := 0; i < length; i++ { + str = str + substr + } + + cmd = compRegEx.ReplaceAllString(cmd, str) + } + + // Syntax like [uuid] can be used to generate GUIDs. + for strings.Contains(cmd, "[uuid]") { + cmd = strings.Replace(cmd, "[uuid]", uuid.New().String(), 1) + } + + return cmd, nil +} diff --git a/bookmarks-db.svg b/bookmarks-db.svg new file mode 100644 index 0000000..c24d208 --- /dev/null +++ b/bookmarks-db.svg @@ -0,0 +1 @@ +bookmarkstextidtextparent_idintegeris_foldertextnametexturltextdescriptiontextmodifiedbookmarks_tagstextbookmark_idtexttagtextmodifiedtagstexttagtextmodified \ No newline at end of file diff --git a/cli/cmd/cmd.go b/cli/cmd/cmd.go new file mode 100644 index 0000000..600c795 --- /dev/null +++ b/cli/cmd/cmd.go @@ -0,0 +1,584 @@ +package cmd + +import ( + "fmt" + "time" + + "github.com/jonathanhope/armaria/lib" +) + +// RootCmd is the top level CLI command for Armaria. +type RootCmd struct { + DB *string `help:"Location of the bookmarks database."` + Formatter Formatter `help:"How to format output: pretty/json." enum:"json,pretty" default:"pretty"` + + Add AddCmd `cmd:"" help:"Add a folder, bookmark, or tag."` + Remove RemoveCmd `cmd:"" help:"Remove a folder, bookmark, or tag."` + Update UpdateCmd `cmd:"" help:"Update a folder or bookmark."` + List ListCmd `cmd:"" help:"List folders, bookmarks, or tags."` +} + +// AddCmd is a CLI command to add a bookmark or folder. +type AddCmd struct { + Book AddBookCmd `cmd:"" help:"Add a bookmark."` + Folder AddFolderCmd `cmd:"" help:"Add a folder."` + Tag AddTagsCmd `cmd:"" help:"Add tags to a bookmark."` +} + +// ListCmd is a CLI command to list bookmarks/folders/tags. +type ListCmd struct { + All ListAllCmd `cmd:"" help:"List bookmarks and folders."` + Books ListBooksCmd `cmd:"" help:"List bookmarks."` + Folders ListFoldersCmd `cmd:"" help:"List folders."` + Tags ListTagsCmd `cmd:"" help:"List tags."` +} + +// UpdateCmd is a CLI command to update a bookmark or folder. +type UpdateCmd struct { + Book UpdateBookCmd `cmd:"" help:"Update a bookmark."` + Folder UpdateFolderCmd `cmd:"" help:"Update a folder."` +} + +// RemoveCmd is a CLI command to remove a folder or bookmark. +type RemoveCmd struct { + Book RemoveBookCmd `cmd:"" help:"Remove a bookmark."` + Folder RemoveFolderCmd `cmd:"" help:"Remove a folder."` + Tag RemoveTagsCmd `cmd:"" help:"Remove tags from a bookmark."` +} + +// AddBookCmd is a CLI command to add a bookmark. +type AddBookCmd struct { + Folder *string `help:"Folder to add this bookmark to."` + Name *string `help:"Name for the bookmark."` + Description *string `help:"Description of the bookmark."` + Tag []string `help:"Tag to apply to the bookmark."` + + URL string `arg:"" name:"url" help:"URL of the bookmark."` +} + +// Run add a bookmark. +func (r *AddBookCmd) Run(ctx *Context) error { + start := time.Now() + + options := lib.DefaultAddBookOptions() + if ctx.DB != nil { + options.WithDB(*ctx.DB) + } + if r.Folder != nil { + options.WithParentID(*r.Folder) + } + if r.Name != nil { + options.WithName(*r.Name) + } + if r.Description != nil { + options.WithDescription(*r.Description) + } + if r.Tag != nil { + options.WithTags(r.Tag) + } + + book, err := lib.AddBook(r.URL, options) + if err != nil { + formatError(ctx.Writer, ctx.Formatter, err) + ctx.ReturnCode(1) + return nil + } + + elapsed := time.Since(start) + + formatBookResults(ctx.Writer, ctx.Formatter, []lib.Book{book}) + formatSuccess(ctx.Writer, ctx.Formatter, fmt.Sprintf("Added in %s", elapsed)) + + return nil +} + +// AddFolderCmd is a CLI command to add a folder. +type AddFolderCmd struct { + Folder *string `help:"Folder to add this folder to."` + + Name string `arg:"" name:"name" help:"Name for the folder."` +} + +// Run add a folder. +func (r *AddFolderCmd) Run(ctx *Context) error { + start := time.Now() + + options := lib.DefaultAddFolderOptions() + if ctx.DB != nil { + options.WithDB(*ctx.DB) + } + if r.Folder != nil { + options.WithParentID(*r.Folder) + } + + book, err := lib.AddFolder(r.Name, options) + if err != nil { + formatError(ctx.Writer, ctx.Formatter, err) + ctx.ReturnCode(1) + return nil + } + + elapsed := time.Since(start) + + formatBookResults(ctx.Writer, ctx.Formatter, []lib.Book{book}) + formatSuccess(ctx.Writer, ctx.Formatter, fmt.Sprintf("Added in %s", elapsed)) + + return nil +} + +// AddTagsCmd is a CLI command to add tags to an existing bookmark. +type AddTagsCmd struct { + Tag []string `help:"Tag to apply to the bookmark."` + + ID string `arg:"" name:"id" help:"ID of the bookmark to add tags to."` +} + +// Run add tags. +func (r *AddTagsCmd) Run(ctx *Context) error { + start := time.Now() + + options := lib.DefaultAddTagsOptions() + if ctx.DB != nil { + options.WithDB(*ctx.DB) + } + + book, err := lib.AddTags(r.ID, r.Tag, options) + if err != nil { + formatError(ctx.Writer, ctx.Formatter, err) + ctx.ReturnCode(1) + return nil + } + + elapsed := time.Since(start) + + formatBookResults(ctx.Writer, ctx.Formatter, []lib.Book{book}) + formatSuccess(ctx.Writer, ctx.Formatter, fmt.Sprintf("Tagged in %s", elapsed)) + + return nil +} + +// ListAllCmd is a CLI command to list bookmarks and folders. +type ListAllCmd struct { + Folder *string `help:"Folder to list bookmarks/folders in."` + After *string `help:"ID of bookmark/folder to return results after."` + Query *string `help:"Query to search bookmarks/folders by."` + Tag []string `help:"Tag to filter bookmarks/folders by."` + Order lib.Order `help:"Field results are ordered on: modified/name." enum:"modified,name" default:"modified"` + Dir lib.Direction `help:"Direction results are ordered by: asc/desc." enum:"asc,desc" default:"asc"` + First *int64 `help:"The max number of bookmarks/folders to return."` +} + +// Run list bookmarks and folders. +func (r *ListAllCmd) Run(ctx *Context) error { + start := time.Now() + + options := lib.DefaultListBooksOptions() + options.WithFolders(true) + options.WithBooks(true) + if ctx.DB != nil { + options.WithDB(*ctx.DB) + } + if r.Folder != nil { + options.WithParentID(*r.Folder) + } + if r.After != nil { + options.WithAfter(*r.After) + } + if r.Query != nil { + options.WithQuery(*r.Query) + } + if r.Tag != nil { + options.WithTags(r.Tag) + } + if r.Order != "" { + options.WithOrder(r.Order) + } + if r.Dir != "" { + options.WithDirection(r.Dir) + } + if r.First != nil { + options.WithFirst(*r.First) + } + + books, err := lib.ListBooks(options) + if err != nil { + formatError(ctx.Writer, ctx.Formatter, err) + ctx.ReturnCode(1) + return nil + } + + elapsed := time.Since(start) + + formatBookResults(ctx.Writer, ctx.Formatter, books) + formatSuccess(ctx.Writer, ctx.Formatter, fmt.Sprintf("Listed in %s", elapsed)) + + return nil +} + +// ListBooksCmd is a CLI command to list bookmarks. +type ListBooksCmd struct { + Folder *string `help:"Folder to list bookmarks in."` + After *string `help:"ID of bookmark to return results after."` + Query *string `help:"Query to search bookmarks by."` + Tag []string `help:"Tag to filter bookmarks by."` + Order lib.Order `help:"Field results are ordered on: modified/name." enum:"modified,name" default:"modified"` + Dir lib.Direction `help:"Direction results are ordered by: asc/desc." enum:"asc,desc" default:"asc"` + First *int64 `help:"The max number of bookmarks to return."` +} + +// Run list bookmarks. +func (r *ListBooksCmd) Run(ctx *Context) error { + start := time.Now() + + options := lib.DefaultListBooksOptions() + options.WithFolders(false) + options.WithBooks(true) + if ctx.DB != nil { + options.WithDB(*ctx.DB) + } + if r.Folder != nil { + options.WithParentID(*r.Folder) + } + if r.After != nil { + options.WithAfter(*r.After) + } + if r.Query != nil { + options.WithQuery(*r.Query) + } + if r.Tag != nil { + options.WithTags(r.Tag) + } + if r.Order != "" { + options.WithOrder(r.Order) + } + if r.Dir != "" { + options.WithDirection(r.Dir) + } + if r.First != nil { + options.WithFirst(*r.First) + } + + books, err := lib.ListBooks(options) + if err != nil { + formatError(ctx.Writer, ctx.Formatter, err) + ctx.ReturnCode(1) + return nil + } + + elapsed := time.Since(start) + + formatBookResults(ctx.Writer, ctx.Formatter, books) + formatSuccess(ctx.Writer, ctx.Formatter, fmt.Sprintf("Listed in %s", elapsed)) + + return nil +} + +// ListFoldersCmd is a CLI command to list folders. +type ListFoldersCmd struct { + Folder *string `help:"Folder to list folders in."` + After *string `help:"ID of folder to return results after."` + Query *string `help:"Query to search folders by."` + Tag []string `help:"Tag to filter folders by."` + Order lib.Order `help:"Field results are ordered on: modified/name." enum:"modified,name" default:"modified"` + Dir lib.Direction `help:"Direction results are ordered by: asc/desc." enum:"asc,desc" default:"asc"` + First *int64 `help:"The max number of folders to return."` +} + +// Run list folders. +func (r *ListFoldersCmd) Run(ctx *Context) error { + start := time.Now() + + options := lib.DefaultListBooksOptions() + options.WithFolders(true) + options.WithBooks(false) + if ctx.DB != nil { + options.WithDB(*ctx.DB) + } + if r.Folder != nil { + options.WithParentID(*r.Folder) + } + if r.After != nil { + options.WithAfter(*r.After) + } + if r.Query != nil { + options.WithQuery(*r.Query) + } + if r.Tag != nil { + options.WithTags(r.Tag) + } + if r.Order != "" { + options.WithOrder(r.Order) + } + if r.Dir != "" { + options.WithDirection(r.Dir) + } + if r.First != nil { + options.WithFirst(*r.First) + } + + books, err := lib.ListBooks(options) + if err != nil { + formatError(ctx.Writer, ctx.Formatter, err) + ctx.ReturnCode(1) + return nil + } + + elapsed := time.Since(start) + + formatBookResults(ctx.Writer, ctx.Formatter, books) + formatSuccess(ctx.Writer, ctx.Formatter, fmt.Sprintf("Listed in %s", elapsed)) + + return nil +} + +// ListTagsCmd is a CLI command to list tags. +type ListTagsCmd struct { + Query *string `help:"Query to search tags by."` + After *string `help:"ID of tags to return results after."` + Dir lib.Direction `help:"Direction results are ordered by: asc/desc." enum:"asc,desc" default:"asc"` + First *int64 `help:"The max number of tags to return."` +} + +// Run list tags. +func (r *ListTagsCmd) Run(ctx *Context) error { + start := time.Now() + + options := lib.DefaultListTagsOptions() + if ctx.DB != nil { + options.WithDB(*ctx.DB) + } + if r.Query != nil { + options.WithQuery(*r.Query) + } + if r.After != nil { + options.WithAfter(*r.After) + } + if r.Dir != "" { + options.WithDirection(r.Dir) + } + if r.First != nil { + options.WithFirst(*r.First) + } + + tags, err := lib.ListTags(options) + if err != nil { + formatError(ctx.Writer, ctx.Formatter, err) + ctx.ReturnCode(1) + return nil + } + + elapsed := time.Since(start) + + formatTagResults(ctx.Writer, ctx.Formatter, tags) + formatSuccess(ctx.Writer, ctx.Formatter, fmt.Sprintf("Listed in %s", elapsed)) + + return nil +} + +// UpdateBookCmd is a CLI command to update a bookmark. +type UpdateBookCmd struct { + Folder *string `help:"Folder to move this bookmark to."` + NoFolder bool `help:"Remove the parent folder."` + Name *string `help:"New name for this bookmark."` + Description *string `help:"New description for this bookmark."` + NoDescription bool `help:"Remove the description."` + URL *string `help:"New URL for this bookmark."` + + ID string `arg:"" name:"id" help:"ID of the bookmark to update."` +} + +// Run update a bookmark. +func (r *UpdateBookCmd) Run(ctx *Context) error { + start := time.Now() + + if r.NoDescription && r.Description != nil { + formatError(ctx.Writer, ctx.Formatter, ErrDescriptionNoDescriptionMutuallyExclusive) + ctx.ReturnCode(1) + return nil + } + + if r.NoFolder && r.Folder != nil { + formatError(ctx.Writer, ctx.Formatter, ErrFolderNoFolderMutuallyExclusive) + ctx.ReturnCode(1) + return nil + } + + options := lib.DefaultUpdateBookOptions() + if ctx.DB != nil { + options.WithDB(*ctx.DB) + } + if r.NoDescription { + options.WithoutDescription() + } + if r.Description != nil { + options.WithDescription(*r.Description) + } + if r.NoFolder { + options.WithoutParentID() + } + if r.Folder != nil { + options.WithParentID(*r.Folder) + } + if r.Name != nil { + options.WithName(*r.Name) + } + if r.URL != nil { + options.WithURL(*r.URL) + } + + book, err := lib.UpdateBook(r.ID, options) + if err != nil { + formatError(ctx.Writer, ctx.Formatter, err) + ctx.ReturnCode(1) + return nil + } + + elapsed := time.Since(start) + + formatBookResults(ctx.Writer, ctx.Formatter, []lib.Book{book}) + formatSuccess(ctx.Writer, ctx.Formatter, fmt.Sprintf("Updated in %s", elapsed)) + + return nil +} + +// UpdateFolderCmd is a CLI command to update a folder. +type UpdateFolderCmd struct { + Name *string `help:"New name for this folder."` + Folder *string `help:"Folder to move this folder to."` + NoFolder bool `help:"Remove the parent folder."` + + ID string `arg:"" name:"id" help:"ID of the folder to update."` +} + +// Run update a folder. +func (r *UpdateFolderCmd) Run(ctx *Context) error { + start := time.Now() + + if r.NoFolder && r.Folder != nil { + formatError(ctx.Writer, ctx.Formatter, ErrFolderNoFolderMutuallyExclusive) + ctx.ReturnCode(1) + return nil + } + + options := lib.DefaultUpdateFolderOptions() + if ctx.DB != nil { + options.WithDB(*ctx.DB) + } + if r.NoFolder { + options.WithoutParentID() + } + if r.Folder != nil { + options.WithParentID(*r.Folder) + } + if r.Name != nil { + options.WithName(*r.Name) + } + + book, err := lib.UpdateFolder(r.ID, options) + if err != nil { + formatError(ctx.Writer, ctx.Formatter, err) + ctx.ReturnCode(1) + return nil + } + + elapsed := time.Since(start) + + formatBookResults(ctx.Writer, ctx.Formatter, []lib.Book{book}) + formatSuccess(ctx.Writer, ctx.Formatter, fmt.Sprintf("Updated in %s", elapsed)) + + return nil +} + +// RemoveBookCmd is a CLI command to remove a bookmark. +type RemoveBookCmd struct { + ID string `arg:"" name:"id" help:"ID of the bookmark to remove."` +} + +// Run remove a bookmark. +func (r *RemoveBookCmd) Run(ctx *Context) error { + start := time.Now() + + options := lib.DefaultRemoveBookOptions() + if ctx.DB != nil { + options.WithDB(*ctx.DB) + } + + err := lib.RemoveBook(r.ID, options) + if err != nil { + formatError(ctx.Writer, ctx.Formatter, err) + ctx.ReturnCode(1) + return nil + } + + elapsed := time.Since(start) + + formatSuccess(ctx.Writer, ctx.Formatter, fmt.Sprintf("Removed in %s", elapsed)) + + return nil +} + +// RemoveFolderCmd is a CLI command to remove a folder. +type RemoveFolderCmd struct { + ID string `arg:"" name:"id" help:"ID of the folder to remove."` +} + +// Run remove a folder. +func (r *RemoveFolderCmd) Run(ctx *Context) error { + start := time.Now() + + options := lib.DefaultRemoveFolderOptions() + if ctx.DB != nil { + options.WithDB(*ctx.DB) + } + + err := lib.RemoveFolder(r.ID, options) + if err != nil { + formatError(ctx.Writer, ctx.Formatter, err) + ctx.ReturnCode(1) + return nil + } + + elapsed := time.Since(start) + + formatSuccess(ctx.Writer, ctx.Formatter, fmt.Sprintf("Removed in %s", elapsed)) + + return nil +} + +// RemoveTagsCmd is a CLI command to add remove tags from an existing bookmark. +type RemoveTagsCmd struct { + Tag []string `help:"Tag to remove from the bookmark."` + + ID string `arg:"" name:"id" help:"ID of the bookmark to remove tags from."` +} + +// Run remove tags. +func (r *RemoveTagsCmd) Run(ctx *Context) error { + start := time.Now() + + options := lib.DefaultRemoveTagsOptions() + if ctx.DB != nil { + options.WithDB(*ctx.DB) + } + + book, err := lib.RemoveTags(r.ID, r.Tag, options) + + if err != nil { + formatError(ctx.Writer, ctx.Formatter, err) + ctx.ReturnCode(1) + return nil + } + + elapsed := time.Since(start) + + formatBookResults(ctx.Writer, ctx.Formatter, []lib.Book{book}) + formatSuccess(ctx.Writer, ctx.Formatter, fmt.Sprintf("Untagged in %s", elapsed)) + + return nil +} + +// RootCmdFactory creates a new RootCmd. +func RootCmdFactory() RootCmd { + var cmd RootCmd + return cmd +} diff --git a/cli/cmd/context.go b/cli/cmd/context.go new file mode 100644 index 0000000..7ce29a9 --- /dev/null +++ b/cli/cmd/context.go @@ -0,0 +1,13 @@ +package cmd + +import ( + "io" +) + +// Context is the context for an invocation of Armaria. +type Context struct { + DB *string // bookmarks database to use + Formatter Formatter // how to format the output + Writer io.Writer // where to write output + ReturnCode func(int) // set the return code +} diff --git a/cli/cmd/errors.go b/cli/cmd/errors.go new file mode 100644 index 0000000..42498b9 --- /dev/null +++ b/cli/cmd/errors.go @@ -0,0 +1,12 @@ +package cmd + +import ( + "errors" +) + +var ( + // ErrFolderNoFolderMutuallyExclusive is returned if folder and no-folder are both provided. + ErrFolderNoFolderMutuallyExclusive = errors.New("folder/no-folder mutually exclusive") + // ErrDescriptionNoDescriptionMutuallyExclusive is returned if no-description and description are both provoded. + ErrDescriptionNoDescriptionMutuallyExclusive = errors.New("description/no-description mutually exclusive") +) diff --git a/cli/cmd/formatters.go b/cli/cmd/formatters.go new file mode 100644 index 0000000..c5df085 --- /dev/null +++ b/cli/cmd/formatters.go @@ -0,0 +1,249 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/jonathanhope/armaria/lib" + "github.com/samber/lo" + "golang.org/x/term" +) + +// Formatter is the way output should be formatted. +type Formatter string + +const ( + FormatterJSON Formatter = "json" + FormatterPretty Formatter = "pretty" +) + +// BookDTO is a bookmark or folder that can be marshalled into JSON. +type BookDTO struct { + ID string `json:"id"` + URL lib.NullString `json:"url"` + Name string `json:"name"` + Description lib.NullString `json:"description"` + ParentID lib.NullString `json:"parentId"` + IsFolder bool `json:"idFolder"` + ParentName lib.NullString `json:"parentName"` + Tags []string `json:"tags"` +} + +// formatSuccess formats a success message. +// Success messages are not written in json mode. +func formatSuccess(writer io.Writer, formatter Formatter, message string) { + switch formatter { + + case FormatterJSON: + + case FormatterPretty: + style := lipgloss. + NewStyle(). + Bold(true). + Foreground(lipgloss.Color("2")). + PaddingLeft(1). + PaddingRight(1). + BorderStyle(lipgloss.RoundedBorder()) + + fmt.Fprintln(writer, style.Render(message)) + } +} + +// formatError formats an error message. +func formatError(writer io.Writer, formatter Formatter, err error) { + var errorString string + if errors.Is(err, lib.ErrURLTooShort) { + errorString = "URL too short" + } else if errors.Is(err, lib.ErrURLTooLong) { + errorString = "URL too long" + } else if errors.Is(err, lib.ErrBookNotFound) { + errorString = "Bookmark not found" + } else if errors.Is(err, lib.ErrFolderNotFound) { + errorString = "Folder not found" + } else if errors.Is(err, lib.ErrNameTooShort) { + errorString = "Name too short" + } else if errors.Is(err, lib.ErrNameTooLong) { + errorString = "Name too long" + } else if errors.Is(err, lib.ErrDescriptionTooShort) { + errorString = "Description too short" + } else if errors.Is(err, lib.ErrDescriptionTooLong) { + errorString = "Description too long" + } else if errors.Is(err, lib.ErrTagTooShort) { + errorString = "Tag too short" + } else if errors.Is(err, lib.ErrTagTooLong) { + errorString = "Tag too long" + } else if errors.Is(err, lib.ErrDuplicateTag) { + errorString = "Tags must be unique" + } else if errors.Is(err, lib.ErrTooManyTags) { + errorString = "Too many tags applied to bookmark" + } else if errors.Is(err, lib.ErrTagInvalidChar) { + errorString = "Tag has invalid chars" + } else if errors.Is(err, lib.ErrNoUpdate) { + errorString = "At least one update is required" + } else if errors.Is(err, ErrFolderNoFolderMutuallyExclusive) { + errorString = "Arguments folder and no-folder are mutually exclusive" + } else if errors.Is(err, ErrDescriptionNoDescriptionMutuallyExclusive) { + errorString = "Arguments description and no-description are mutually exclusive" + } else if errors.Is(err, lib.ErrTagNotFound) { + errorString = "Tag not found" + } else if errors.Is(err, lib.ErrFirstTooSmall) { + errorString = "First too small" + } else if errors.Is(err, lib.ErrQueryTooShort) { + errorString = "Query too short" + } else { + errorString = err.Error() + } + + switch formatter { + + case FormatterJSON: + fmt.Fprintf(writer, "\"%s\"\n", errorString) + + case FormatterPretty: + style := lipgloss. + NewStyle(). + Bold(true). + Foreground(lipgloss.Color("9")). + PaddingLeft(1). + PaddingRight(1). + BorderStyle(lipgloss.RoundedBorder()) + + fmt.Fprintln(writer, style.Render(errorString)) + } +} + +// formatBookResults formats a collection of bookmarks/folders. +func formatBookResults(writer io.Writer, formatter Formatter, books []lib.Book) { + + switch formatter { + case FormatterJSON: + dtos := lo.Map(books, func(x lib.Book, index int) BookDTO { + return BookDTO{ + ID: x.ID, + URL: lib.NullStringFromPtr(x.URL), + Name: x.Name, + Description: lib.NullStringFromPtr(x.Description), + ParentID: lib.NullStringFromPtr(x.ParentID), + IsFolder: x.IsFolder, + ParentName: lib.NullStringFromPtr(x.ParentName), + Tags: x.Tags, + } + }) + + json, err := json.Marshal(&dtos) + if err != nil { + panic(err) + } + + fmt.Fprintln(writer, string(json)) + + case FormatterPretty: + width, _, err := term.GetSize(int(os.Stdin.Fd())) + if err != nil { + panic(err) + } + + headerStyle := lipgloss. + NewStyle(). + Bold(true). + PaddingLeft(1). + PaddingRight(1). + Width(16) + + rowStyle := lipgloss. + NewStyle(). + PaddingLeft(1). + PaddingRight(1). + Width(width - 16) + + for _, book := range books { + rows := [][]string{ + {formatIsFolder(book.IsFolder), book.ID}, + {"Name", book.Name}, + {"URL", formatNullableString(book.URL)}, + {"Description", formatNullableString(book.Description)}, + {"Folder", formatNullableString(book.ParentName)}, + {"Tags", formatTags(book.Tags)}, + } + + table := table.New(). + Border(lipgloss.RoundedBorder()). + BorderRow(true). + BorderColumn(true). + Width(width). + StyleFunc(func(row, col int) lipgloss.Style { + switch { + case col == 0: + return headerStyle + default: + return rowStyle + } + }). + Rows(rows...) + + fmt.Fprintln(writer, table) + } + } +} + +// formatTagResults formats a collection of tags. +func formatTagResults(writer io.Writer, formatter Formatter, tags []string) { + switch formatter { + + case FormatterJSON: + json, err := json.Marshal(&tags) + + if err != nil { + panic(err) + } + + fmt.Fprintln(writer, string(json)) + + case FormatterPretty: + width, _, err := term.GetSize(int(os.Stdin.Fd())) + if err != nil { + panic(err) + } + + style := lipgloss. + NewStyle(). + Bold(true). + PaddingLeft(1). + PaddingRight(1). + BorderStyle(lipgloss.RoundedBorder()). + MaxWidth(width - 2) + + for _, tag := range tags { + fmt.Fprintln(writer, style.Render(fmt.Sprintf("🏷 %s", tag))) + } + } +} + +// formatIsFolder formats an is folder value. +func formatIsFolder(isFolder bool) string { + if isFolder { + return "🗁" + } + + return "🕮" +} + +// formatNullableString formats a nullable string. +func formatNullableString(str *string) string { + if str != nil { + return *str + } + + return "NULL" +} + +// formatTags formats a tags value. +func formatTags(tags []string) string { + return strings.Join(tags, ", ") +} diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..e2e0e4d --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,17 @@ +module github.com/jonathanhope/armaria/cli + +go 1.20 + +require ( + github.com/alecthomas/kong v0.8.1 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/lipgloss v0.9.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + golang.org/x/sys v0.14.0 // indirect + golang.org/x/term v0.14.0 // indirect +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 0000000..f627998 --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,39 @@ +github.com/alecthomas/kong v0.8.0 h1:ryDCzutfIqJPnNn0omnrgHLbAggDQM2VWHikE1xqK7s= +github.com/alecthomas/kong v0.8.0/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U= +github.com/alecthomas/kong v0.8.1 h1:acZdn3m4lLRobeh3Zi2S2EpnXTd1mOL6U7xVml+vfkY= +github.com/alecthomas/kong v0.8.1/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8= +golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..69c79fc --- /dev/null +++ b/cli/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "os" + + "github.com/alecthomas/kong" + "github.com/jonathanhope/armaria/cli/cmd" +) + +func main() { + rootCmd := cmd.RootCmdFactory() + ctx := kong.Parse(&rootCmd) + + err := ctx.Run(&cmd.Context{ + DB: rootCmd.DB, + Formatter: rootCmd.Formatter, + Writer: os.Stdout, + ReturnCode: os.Exit}) + + ctx.FatalIfErrorf(err) +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..95a75e7 --- /dev/null +++ b/flake.lock @@ -0,0 +1,44 @@ +{ + "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1696426674, + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1698611440, + "narHash": "sha256-jPjHjrerhYDy3q9+s5EAsuhyhuknNfowY6yt6pjn9pc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0cbe9f69c234a7700596e943bfae7ef27a31b735", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-compat": "flake-compat", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..9810b51 --- /dev/null +++ b/flake.nix @@ -0,0 +1,29 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-compat = { + url = "github:edolstra/flake-compat"; + flake = false; + }; + }; + + outputs = { self, nixpkgs, flake-compat }: + let pkgs = import nixpkgs { + system = "x86_64-linux"; + }; + in + { + devShell.x86_64-linux = + pkgs.mkShell { + buildInputs = with pkgs;[ + go_1_20 + gopls + go-task + golangci-lint + goose + gcc + docker + ]; + }; + }; +} diff --git a/go.work b/go.work new file mode 100644 index 0000000..e7a0580 --- /dev/null +++ b/go.work @@ -0,0 +1,5 @@ +go 1.20 + +use ./cli +use ./bdd +use ./lib diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..85330c4 --- /dev/null +++ b/go.work.sum @@ -0,0 +1,5 @@ +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= diff --git a/lib/add_book.go b/lib/add_book.go new file mode 100644 index 0000000..4b8ae4c --- /dev/null +++ b/lib/add_book.go @@ -0,0 +1,107 @@ +package lib + +import ( + "github.com/samber/lo" +) + +// addBookOptions are the optional arguments for AddBook. +type addBookOptions struct { + db NullString + name NullString + description NullString + parentID NullString + tags []string +} + +// DefaultAddBookOptions are the default options for AddBook. +func DefaultAddBookOptions() addBookOptions { + return addBookOptions{} +} + +// WithDB sets the location of the bookmarks database. +func (o *addBookOptions) WithDB(db string) { + o.db = NullStringFrom(db) +} + +// WithName sets the bookmark's name. +func (o *addBookOptions) WithName(name string) { + o.name = NullStringFrom(name) +} + +// WithDescription sets the bookmark's description. +func (o *addBookOptions) WithDescription(description string) { + o.description = NullStringFrom(description) +} + +// WithParentID sets the bookmark's parent ID. +func (o *addBookOptions) WithParentID(parentID string) { + o.parentID = NullStringFrom(parentID) +} + +// WithTags sets the bookmark's tags. +func (o *addBookOptions) WithTags(tags []string) { + o.tags = tags +} + +// AddBook adds a bookmark to the bookmarks database. +func AddBook(url string, options addBookOptions) (Book, error) { + return queryWithTransaction(options.db, connectDB, func(tx transaction) (Book, error) { + var book Book + + // Default name to URL if not provided. + if !options.name.Valid { + options.name = NullStringFrom(url) + } + + if err := validateURL(NullStringFrom(url)); err != nil { + return book, err + } + + if err := validateName(options.name); err != nil { + return book, err + } + + if err := validateDescription(options.description); err != nil { + return book, err + } + + if err := validateParentID(tx, options.parentID); err != nil { + return book, err + } + + if err := validateTags(options.tags, make([]string, 0)); err != nil { + return book, err + } + + id, err := addBookDB(tx, url, options.name.String, options.description, options.parentID) + if err != nil { + return book, err + } + + existingTags, err := getTagsDB(tx, getTagsDBArgs{ + tagsFilter: options.tags, + }) + if err != nil { + return book, err + } + + tagsToAdd, _ := lo.Difference(options.tags, existingTags) + if err = addTagsDB(tx, tagsToAdd); err != nil { + return book, err + } + + if err = linkTagsDB(tx, id, options.tags); err != nil { + return book, err + } + + books, err := getBooksDB(tx, getBooksDBArgs{ + idFilter: id, + includeBooks: true, + }) + if err != nil { + return book, err + } + + return books[0], err + }) +} diff --git a/lib/add_folder.go b/lib/add_folder.go new file mode 100644 index 0000000..7dbfc03 --- /dev/null +++ b/lib/add_folder.go @@ -0,0 +1,52 @@ +package lib + +// addFolderOptions are the optional arguments for AddFolder. +type addFolderOptions struct { + db NullString + parentID NullString +} + +// DefaultAddFolderOptions are the default options for AddFolder. +func DefaultAddFolderOptions() addFolderOptions { + return addFolderOptions{} +} + +// WithDB sets the location of the bookmarks database. +func (o *addFolderOptions) WithDB(db string) { + o.db = NullStringFrom(db) +} + +// WithParentID sets the folders' parent ID. +func (o *addFolderOptions) WithParentID(parentID string) { + o.parentID = NullStringFrom(parentID) +} + +// AddFolder adds a folder to the bookmarks database. +func AddFolder(name string, options addFolderOptions) (Book, error) { + return queryWithTransaction(options.db, connectDB, func(tx transaction) (Book, error) { + var book Book + + if err := validateName(NullStringFrom(name)); err != nil { + return book, err + } + + if err := validateParentID(tx, options.parentID); err != nil { + return book, err + } + + id, err := addFolderDB(tx, name, options.parentID) + if err != nil { + return book, err + } + + books, err := getBooksDB(tx, getBooksDBArgs{ + idFilter: id, + includeFolders: true, + }) + if err != nil { + return book, err + } + + return books[0], err + }) +} diff --git a/lib/add_tags.go b/lib/add_tags.go new file mode 100644 index 0000000..7e3c203 --- /dev/null +++ b/lib/add_tags.go @@ -0,0 +1,69 @@ +package lib + +import ( + "github.com/samber/lo" +) + +// addTagsOptions are the optional arguments for AddTags. +type addTagsOptions struct { + db NullString +} + +// DefaultAddTagsOptions are the default options for AddTags. +func DefaultAddTagsOptions() addTagsOptions { + return addTagsOptions{} +} + +// WithDB sets the location of the bookmarks database. +func (o *addTagsOptions) WithDB(db string) { + o.db = NullStringFrom(db) +} + +// AddTags adds tags to a bookmark in the bookmarks database. +func AddTags(id string, tags []string, options addTagsOptions) (Book, error) { + return queryWithTransaction(options.db, connectDB, func(tx transaction) (Book, error) { + var book Book + + books, err := getBooksDB(tx, getBooksDBArgs{ + idFilter: id, + includeBooks: true, + }) + if err != nil { + return book, err + } + + if len(books) != 1 || books[0].IsFolder { + return book, ErrBookNotFound + } + + if err := validateTags(tags, books[0].Tags); err != nil { + return book, err + } + + existingTags, err := getTagsDB(tx, getTagsDBArgs{ + tagsFilter: tags, + }) + if err != nil { + return book, err + } + + tagsToAdd, _ := lo.Difference(tags, existingTags) + if err = addTagsDB(tx, tagsToAdd); err != nil { + return book, err + } + + if err = linkTagsDB(tx, id, tags); err != nil { + return book, err + } + + books, err = getBooksDB(tx, getBooksDBArgs{ + idFilter: id, + includeBooks: true, + }) + if err != nil { + return book, err + } + + return books[0], nil + }) +} diff --git a/lib/book.go b/lib/book.go new file mode 100644 index 0000000..3d986a3 --- /dev/null +++ b/lib/book.go @@ -0,0 +1,13 @@ +package lib + +// Book is a bookmark or a folder. +type Book struct { + ID string // unique identifier of a bookmark/folder + URL *string // address of a bookmark; not used for folders + Name string // name of a bookmark/folder + Description *string // description of a bookmark/folder + ParentID *string // optional ID of the parent folder for a bookmark/folder + IsFolder bool // true if folder, and false otherwise + ParentName *string // name of parent folder if bookmark/folder has one + Tags []string // tags applied to the bookmark +} diff --git a/lib/db.go b/lib/db.go new file mode 100644 index 0000000..cf25deb --- /dev/null +++ b/lib/db.go @@ -0,0 +1,441 @@ +package lib + +import ( + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/nullism/bqb" + "github.com/samber/lo" +) + +// This file contains the low level logic to access the bookmarks DB. + +// types + +// bookDto is a DTO to stuff DB results into. +type bookDto struct { + ID string `db:"id"` + URL NullString `db:"url"` + Name string `db:"name"` + Description NullString `db:"description"` + ParentID NullString `db:"parent_id"` + IsFolder bool `db:"is_folder"` + ParentName NullString `db:"parent_name"` + Tags string `db:"tags"` +} + +// create + +// addBookDB inserts a book into the bookmarks database. +func addBookDB(tx transaction, url string, name string, description NullString, parentID NullString) (string, error) { + id := uuid.New().String() + + insert := bqb.New(`INSERT INTO "bookmarks"("id", "url", "is_folder", "name", "description", "parent_id")`) + insert.Space("VALUES(?, ?, ?, ?, ?, ?)", id, url, false, name, description, parentID) + + err := exec(tx, insert) + return id, err +} + +// addFolderDB inserts a folder into the bookmarks database. +func addFolderDB(tx transaction, name string, parentID NullString) (string, error) { + id := uuid.New().String() + + insert := bqb.New(`INSERT INTO "bookmarks"("id", "is_folder", "name", "parent_id")`) + insert.Space("VALUES(?, ?, ?, ?)", id, true, name, parentID) + + err := exec(tx, insert) + return id, err +} + +// addTagsDB inserts tags into the bookmarks database. +func addTagsDB(tx transaction, tags []string) error { + if len(tags) == 0 { + return nil + } + + insert := bqb.New(`INSERT INTO "tags"("tag")`) + insert.Space(`VALUES (?)`, tags[0]) + + for _, tag := range tags[1:] { + insert.Comma(`(?)`, tag) + } + + return exec(tx, insert) +} + +// linkTagsDB adds tags to bookmark. +func linkTagsDB(tx transaction, bookmarkID string, tags []string) error { + if len(tags) == 0 { + return nil + } + + insert := bqb.New(`INSERT INTO "bookmarks_tags"("bookmark_id", "tag")`) + insert.Space(`VALUES (?, ?)`, bookmarkID, tags[0]) + + for _, tag := range tags[1:] { + insert.Comma(`(?, ?)`, bookmarkID, tag) + } + + return exec(tx, insert) +} + +// read + +// getBooksDBArgs are the args for getBooksDB. +type getBooksDBArgs struct { + idFilter string + includeBooks bool + includeFolders bool + parentID NullString + query NullString + tags []string + after NullString + order Order + direction Direction + first NullInt64 +} + +// getBooksDB lists bookmarks/folders in the bookmarks DB. +func getBooksDB(tx transaction, args getBooksDBArgs) ([]Book, error) { + tags := bqb.New(`SELECT GROUP_CONCAT("tag")`) + tags.Space(`FROM "bookmarks_tags"`) + tags.Space(`WHERE "bookmark_id" = "child"."id"`) + + books := bqb.New(`SELECT "child"."id"`) + books.Comma(`"child"."url"`) + books.Comma(`"child"."name"`) + books.Comma(`"child"."description"`) + books.Comma(`"child"."parent_id"`) + books.Comma(`"child"."is_folder"`) + books.Comma(`"parent"."name" AS "parent_name"`) + books.Comma(`IFNULL((?), '') AS "tags"`, tags) + books.Space(`FROM "bookmarks" AS "child"`) + books.Space(`LEFT JOIN "bookmarks" AS "parent" ON "parent"."id" = "child"."parent_id"`) + + where := bqb.Optional("WHERE") + + if args.idFilter != "" { + where.And(`"child"."id" = ?`, args.idFilter) + } + + if args.includeBooks && !args.includeFolders { + where.And(`"child"."is_folder" = ?`, false) + } + + if args.includeFolders && !args.includeBooks { + where.And(`"child"."is_folder" = ?`, true) + } + + if args.parentID.Dirty && args.parentID.Valid { + where.And(`"child"."parent_id" = ?`, args.parentID.String) + } + + if args.query.Dirty && args.query.Valid { + searchFilter := bqb.New(`SELECT "id"`) + searchFilter.Space(`FROM "bookmarks_fts"`) + searchFilter.Space(`WHERE "name" LIKE ?`, fmt.Sprintf("%%%s%%", args.query.String)) + searchFilter.Space(`OR "description" LIKE ?`, fmt.Sprintf("%%%s%%", args.query.String)) + searchFilter.Space(`OR "url" LIKE ?`, fmt.Sprintf("%%%s%%", args.query.String)) + + where.And(`"child"."id" IN (?)`, searchFilter) + } + + if len(args.tags) > 0 { + tagsFilter := bqb.New(`SELECT "bookmark_id"`) + tagsFilter.Space(`FROM "bookmarks_tags"`) + tagsFilter.Space(`WHERE "tag" IN (?)`, args.tags) + + where.And(`"child"."id" IN (?)`, tagsFilter) + } + + if args.after.Dirty && args.after.Valid { + if args.order == OrderName && args.direction == DirectionAsc { + where.And(`("child"."name" > (SELECT "name" FROM "bookmarks" WHERE "id" = ?)`, args.after.String) + } else if args.order == OrderName && args.direction == DirectionDesc { + where.And(`("child"."name" < (SELECT "name" FROM "bookmarks" WHERE "id" = ?)`, args.after.String) + } else if args.order == OrderModified && args.direction == DirectionAsc { + where.And(`("child"."modified" > (SELECT "modified" FROM "bookmarks" WHERE "id" = ?)`, args.after.String) + } else if args.order == OrderModified && args.direction == DirectionDesc { + where.And(`("child"."modified" < (SELECT "modified" FROM "bookmarks" WHERE "id" = ?)`, args.after.String) + } + + if args.order == OrderName { + where.Or(`("child"."name" = (SELECT "name" from "bookmarks" WHERE "id" = ?) AND "child"."id" > ?))`, args.after.String, args.after.String) + } else if args.order == OrderModified { + where.Or(`("child"."modified" = (SELECT "modified" from "bookmarks" WHERE "id" = ?) AND "child"."id" > ?))`, args.after.String, args.after.String) + } + } + + books.Space("?", where) + + if args.direction == DirectionAsc && args.order == OrderName { + books.Space(`ORDER BY "child"."name" ASC`) + } else if args.direction == DirectionDesc && args.order == OrderName { + books.Space(`ORDER BY "child"."name" DESC`) + } else if args.direction == DirectionAsc && args.order == OrderModified { + books.Space(`ORDER BY "child"."modified" ASC`) + } else if args.direction == DirectionDesc && args.order == OrderModified { + books.Space(`ORDER BY "child"."modified" DESC`) + } + + if args.first.Dirty && args.first.Valid { + books.Space(`LIMIT ?`, args.first.Int64) + } + + results, err := query[bookDto](tx, books) + return lo.Map(results, func(x bookDto, index int) Book { + return Book{ + ID: x.ID, + URL: PtrFromNullString(x.URL), + Name: x.Name, + Description: PtrFromNullString(x.Description), + ParentID: PtrFromNullString(x.ParentID), + IsFolder: x.IsFolder, + ParentName: PtrFromNullString(x.ParentName), + Tags: parseTags(x.Tags), + } + }), err +} + +// getTagsDBArgs are the args for getTagsDB. +type getTagsDBArgs struct { + idFilter NullString + tagsFilter []string + query NullString + after NullString + direction Direction + first NullInt64 +} + +// getTagsDB lists tags in the bookmarks DB. +func getTagsDB(tx transaction, args getTagsDBArgs) ([]string, error) { + tags := bqb.New(`SELECT "tag"`) + tags.Space(`FROM "tags"`) + + where := bqb.Optional(`WHERE`) + + if len(args.tagsFilter) > 0 { + where.And(`"tag" IN (?)`, args.tagsFilter) + } + + if args.query.Dirty && args.query.Valid { + searchFilter := bqb.New(`SELECT "tag"`) + searchFilter.Space(`FROM "tags_fts"`) + searchFilter.Space(`WHERE "tag" LIKE ?`, fmt.Sprintf("%%%s%%", args.query.String)) + + where.And(`"tag" IN (?)`, searchFilter) + } + + if args.after.Dirty && args.after.Valid { + if args.direction == DirectionAsc { + where.And(`"tag" > ?`, args.after.String) + } else { + where.And(`"tag" < ?`, args.after.String) + } + } + + tags.Space(`?`, where) + + if args.direction == DirectionAsc { + tags.Space(`ORDER BY "tag" ASC, "id" ASC`) + } else { + tags.Space(`ORDER BY "tag" DESC, "id" ASC`) + } + + if args.first.Dirty && args.first.Valid { + tags.Space(`LIMIT ?`, args.first.Int64) + } + + return query[string](tx, tags) +} + +// bookFolderExistsDB returns true if the target book or folder exists. +func bookFolderExistsDB(tx transaction, ID string, isFolder bool) (bool, error) { + books := bqb.New(`SELECT COUNT(1) AS "num"`) + books.Space(`FROM "bookmarks"`) + books.Space(`WHERE "bookmarks"."id" = ?`, ID) + books.Space(`AND "bookmarks"."is_folder" = ?`, isFolder) + + count, err := count(tx, books) + return count == 1, err +} + +// getParentAndChildren gets a parent and all of its children. +func getParentAndChildren(tx transaction, ID string) ([]Book, error) { + tags := bqb.New(`SELECT GROUP_CONCAT("tag")`) + tags.Space(`FROM "bookmarks_tags"`) + tags.Space(`WHERE "bookmark_id" = "child"."id"`) + + first := bqb.New(`SELECT "child"."id"`) + first.Comma(`"child"."url"`) + first.Comma(`"child"."name"`) + first.Comma(`"child"."description"`) + first.Comma(`"child"."parent_id"`) + first.Comma(`"child"."is_folder"`) + first.Comma(`"parent"."name" AS "parent"`) + first.Comma(`IFNULL((?), '') AS "tags"`, tags) + first.Space(`FROM "bookmarks" AS "child"`) + first.Space(`LEFT JOIN "bookmarks" AS "parent" ON "parent"."id" = "child"."parent_id"`) + first.Space(`WHERE "child"."id" = ?`, ID) + + rest := bqb.New(`SELECT "child"."id"`) + rest.Comma(`"child"."url"`) + rest.Comma(`"child"."name"`) + rest.Comma(`"child"."description"`) + rest.Comma(`"child"."parent_id"`) + rest.Comma(`"child"."is_folder"`) + rest.Comma(`"parent"."name" AS "parent"`) + rest.Comma(`IFNULL((?), '') AS "tags"`, tags) + rest.Space(`FROM "bookmarks" AS "child"`) + rest.Space(`LEFT JOIN "bookmarks" AS "parent" ON "parent"."id" = "child"."parent_id"`) + rest.Space(`INNER JOIN BOOK ON BOOK.id = "child"."parent_id"`) + + books := bqb.New(`WITH RECURSIVE BOOK AS (? UNION ALL ?)`, first, rest) + books.Space(`SELECT "id"`) + books.Comma(`"url"`) + books.Comma(`"name"`) + books.Comma(`"description"`) + books.Comma(`"parent_id"`) + books.Comma(`"is_folder"`) + books.Comma(`"parent"`) + books.Comma(`"tags"`) + books.Space(`FROM BOOK`) + + results, err := query[bookDto](tx, books) + return lo.Map(results, func(x bookDto, index int) Book { + return Book{ + ID: x.ID, + URL: PtrFromNullString(x.URL), + Name: x.Name, + Description: PtrFromNullString(x.Description), + ParentID: PtrFromNullString(x.ParentID), + IsFolder: x.IsFolder, + ParentName: PtrFromNullString(x.ParentName), + Tags: parseTags(x.Tags), + } + }), err + +} + +// update + +// updateBookDBArgs are the args for updateBookDB. +type updateBookDBArgs struct { + name NullString + url NullString + description NullString + parentID NullString +} + +// updateBookDB updates a book in the bookmarks database. +func updateBookDB(tx transaction, ID string, args updateBookDBArgs) error { + update := bqb.New(`UPDATE "bookmarks"`) + set := bqb.Optional(`SET`) + + if args.name.Dirty { + set.Comma(`"name" = ?`, args.name) + } + + if args.url.Dirty { + set.Comma(`"url" = ?`, args.url) + } + + if args.description.Dirty { + set.Comma(`"description" = ?`, args.description) + } + + if args.parentID.Dirty { + set.Comma(`"parent_id" = ?`, args.parentID) + } + + update.Space(`?`, set) + update.Space(`WHERE "id" = ?`, ID) + update.Space(`AND "is_folder" = ?`, false) + + return exec(tx, update) +} + +// updateFolderDBArgs are the args for updateFolderDB. +type updateFolderDBArgs struct { + name NullString + parentID NullString +} + +// updateFolderDB updates a folder in the bookmarks database. +func updateFolderDB(tx transaction, ID string, args updateFolderDBArgs) error { + update := bqb.New(`UPDATE "bookmarks"`) + set := bqb.Optional(`SET`) + + if args.name.Dirty { + set.Comma(`"name" = ?`, args.name) + } + + if args.parentID.Dirty { + set.Comma(`"parent_id" = ?`, args.parentID) + } + + update.Space(`?`, set) + update.Space(`WHERE "id" = ?`, ID) + update.Space(`AND "is_folder" = ?`, true) + + return exec(tx, update) +} + +// delete + +// unlinkTagsDB removes tags from a bookmark. +func unlinkTagsDB(tx transaction, ID string, tags []string) error { + if len(tags) == 0 { + return nil + } + + remove := bqb.New(`DELETE FROM "bookmarks_tags"`) + remove.Space(`WHERE "bookmark_id" = ?`, ID) + remove.Space(`AND "tag" IN (?)`, tags) + + return exec(tx, remove) +} + +// removeBookDB deletes a bookmark from the bookmarks DB. +func removeBookDB(tx transaction, ID string) error { + remove := bqb.New(`DELETE FROM "bookmarks"`) + remove.Space(`WHERE id = ?`, ID) + remove.Space(`AND is_folder = ?`, false) + + return exec(tx, remove) +} + +// removeFolderDB deletes a folder from the bookmarks DB. +func removeFolderDB(tx transaction, ID string) error { + remove := bqb.New(`DELETE FROM "bookmarks"`) + remove.Space(`WHERE id = ?`, ID) + remove.Space(`AND is_folder = ?`, true) + + return exec(tx, remove) +} + +// cleanOrphanedTagsDB removes any tags that aren't applied to a bookmark. +func cleanOrphanedTagsDB(tx transaction, tags []string) error { + existing := bqb.New(`SELECT 1`) + existing.Space(`FROM "bookmarks_tags"`) + existing.Space(`WHERE "bookmarks_tags"."tag" = "tags"."tag"`) + + remove := bqb.New(`DELETE FROM "tags"`) + remove.Space(`WHERE "tag" IN (?)`, tags) + remove.Space(`AND NOT EXISTS (?)`, existing) + + return exec(tx, remove) +} + +// helpers + +// parseTags parses the tags coming back from the database. +func parseTags(tags string) []string { + if tags == "" { + return make([]string, 0) + } + + return strings.Split(tags, ",") +} diff --git a/lib/db_test.go b/lib/db_test.go new file mode 100644 index 0000000..8d333ea --- /dev/null +++ b/lib/db_test.go @@ -0,0 +1,29 @@ +package lib + +import ( + "fmt" + "reflect" + "testing" +) + +func TestParseTags(t *testing.T) { + type test struct { + input string + want []string + } + + tests := []test{ + {input: "", want: []string{}}, + {input: "one,two,three", want: []string{"one", "two", "three"}}, + } + + for _, tc := range tests { + t.Run(fmt.Sprintf("%+v", tc.input), func(t *testing.T) { + got := parseTags(tc.input) + equal := reflect.DeepEqual(got, tc.want) + if !equal { + t.Errorf("got %+v; want %+v", got, tc.want) + } + }) + } +} diff --git a/lib/direction.go b/lib/direction.go new file mode 100644 index 0000000..c53d598 --- /dev/null +++ b/lib/direction.go @@ -0,0 +1,9 @@ +package lib + +// Direction is the direction results are ordered by. +type Direction string + +const ( + DirectionAsc Direction = "asc" + DirectionDesc Direction = "desc" +) diff --git a/lib/errors.go b/lib/errors.go new file mode 100644 index 0000000..e4bebc9 --- /dev/null +++ b/lib/errors.go @@ -0,0 +1,48 @@ +package lib + +import ( + "errors" +) + +var ( + // ErrUnexpected is returned when an unexpected error occurs. + ErrUnexpected = errors.New("unexpected error") + // ErrNoUpdate is returned when an update is requested with no updates. + ErrNoUpdate = errors.New("no update") + // ErrBookNotFound is returned when a target bookmark was not found. + ErrBookNotFound = errors.New("bookmark not found") + // ErrFolderNotFound is returned when a target folder was not found. + ErrFolderNotFound = errors.New("folder not found") + // ErrTagNotFound is returned when a target tag was not found. + ErrTagNotFound = errors.New("tag not found") + // ErrURLTooShort is returned when a provided URL is too short. + ErrURLTooShort = errors.New("URL too short") + // ErrURLTooLong is too long when a provided URL is too long. + ErrURLTooLong = errors.New("URL too long") + // ErrNameTooShort is returned when a provided name is too short. + ErrNameTooShort = errors.New("name too short") + // ErrNameTooLong is returned when a provided name is too long. + ErrNameTooLong = errors.New("name too long") + // ErrDescriptionTooShort is returned when a provided description is too short. + ErrDescriptionTooShort = errors.New("description too short") + // ErrDescriptionTooLong is returned when a provided description is too long. + ErrDescriptionTooLong = errors.New("description too long") + // ErrTagTooShort is returned when a provided tag is too short. + ErrTagTooShort = errors.New("tag too short") + // ErrTagTooLong is returned when a provided tag is too long. + ErrTagTooLong = errors.New("tag too long") + // ErrDuplicateTag is returned when a tag is applied twice to a bookmark. + ErrDuplicateTag = errors.New("tags must be unique") + // ErrTooManyTags is returned when too many tags have been applied to bookmark. + ErrTooManyTags = errors.New("too many tags") + // ErrTagInvalidChar is returned when a provided tag has an invalid character. + ErrTagInvalidChar = errors.New("tag had invalid chars") + // ErrFirstTooSmall is returned when a provided first is too small. + ErrFirstTooSmall = errors.New("first too small") + // ErrInvalidOrder is returned when a provided order is invalid. + ErrInvalidOrder = errors.New("invalid order") + // ErrInvalidDirection is returned when a provided direction is invalid. + ErrInvalidDirection = errors.New("invalid direction") + // ErrQueryTooShort is returned when a provided query is too short. + ErrQueryTooShort = errors.New("query too short") +) diff --git a/lib/go.mod b/lib/go.mod new file mode 100644 index 0000000..1c4163f --- /dev/null +++ b/lib/go.mod @@ -0,0 +1,18 @@ +module github.com/jonathanhope/armaria/lib + +go 1.20 + +require ( + github.com/blockloop/scan/v2 v2.5.0 + github.com/google/uuid v1.4.0 + github.com/mattn/go-sqlite3 v1.14.18 + github.com/nullism/bqb v1.7.1 + github.com/pressly/goose/v3 v3.15.1 + github.com/samber/lo v1.38.1 + gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0 +) + +require ( + golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect + golang.org/x/text v0.13.0 // indirect +) diff --git a/lib/go.sum b/lib/go.sum new file mode 100644 index 0000000..e19f521 --- /dev/null +++ b/lib/go.sum @@ -0,0 +1,40 @@ +github.com/blockloop/scan/v2 v2.5.0 h1:/yNcCwftYn3wf5BJsJFO9E9P48l45wThdUnM3WcDF+o= +github.com/blockloop/scan/v2 v2.5.0/go.mod h1:OFYyMocUdRW3DUWehPI/fSsnpNMUNiyUaYXRMY5NMIY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI= +github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/nullism/bqb v1.7.1 h1:n2BOwqJ3qggm/Z2CrNpzy9lWUqhyq7gy50xGVh+rdBw= +github.com/nullism/bqb v1.7.1/go.mod h1:4Z4vvPss9ms9dtLHpI4tUPtysmCAZfbm44lbsP3VDBY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pressly/goose/v3 v3.15.1 h1:dKaJ1SdLvS/+HtS8PzFT0KBEtICC1jewLXM+b3emlv8= +github.com/pressly/goose/v3 v3.15.1/go.mod h1:0E3Yg/+EwYzO6Rz2P98MlClFgIcoujbVRs575yi3iIM= +github.com/proullon/ramsql v0.0.1 h1:tI7qN48Oj1LTmgdo4aWlvI9z45a4QlWaXlmdJ+IIfbU= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= +github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM= +golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= +golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= +gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0 h1:FVCohIoYO7IJoDDVpV2pdq7SgrMH6wHnuTyrdrxJNoY= +gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0/go.mod h1:OdE7CF6DbADk7lN8LIKRzRJTTZXIjtWgA5THM5lhBAw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo= +modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q= +modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0= +modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/lib/list_books.go b/lib/list_books.go new file mode 100644 index 0000000..05d42e3 --- /dev/null +++ b/lib/list_books.go @@ -0,0 +1,114 @@ +package lib + +// listBooksOptions are the optional arguments for ListBooks. +type listBooksOptions struct { + db NullString + includeBookmarks bool + includeFolders bool + parentID NullString + query NullString + tags []string + after NullString + order Order + direction Direction + first NullInt64 +} + +// DefaultListBooksOptions are the default options for ListBooks. +func DefaultListBooksOptions() listBooksOptions { + return listBooksOptions{ + includeBookmarks: true, + includeFolders: true, + order: OrderModified, + direction: DirectionAsc, + } +} + +// WithDB sets the location of the bookmarks database. +func (o *listBooksOptions) WithDB(db string) { + o.db = NullStringFrom(db) +} + +// WithBooks sets whether to include bookmark results. +func (o *listBooksOptions) WithBooks(include bool) { + o.includeBookmarks = include +} + +// WithIncludeFolder sets whether to include folder results. +func (o *listBooksOptions) WithFolders(include bool) { + o.includeFolders = include +} + +// WithParentID filters by parent ID. +func (o *listBooksOptions) WithParentID(parentID string) { + o.parentID = NullStringFrom(parentID) +} + +// WithQuery searches on name, URL, description. +func (o *listBooksOptions) WithQuery(query string) { + o.query = NullStringFrom(query) +} + +// WithTags filters by tag. +func (o *listBooksOptions) WithTags(tags []string) { + o.tags = tags +} + +// WithAfter returns results after an ID. +func (o *listBooksOptions) WithAfter(after string) { + o.after = NullStringFrom(after) +} + +// WithOrder sets the column to order on. +func (o *listBooksOptions) WithOrder(order Order) { + o.order = order +} + +// WithDirection sets the direction to order by. +func (o *listBooksOptions) WithDirection(direction Direction) { + o.direction = direction +} + +// withFirst sets the max number of results to return. +func (o *listBooksOptions) WithFirst(first int64) { + o.first = NullInt64From(first) +} + +// ListBooks lists bookmarks and folders in the bookmarks database. +func ListBooks(options listBooksOptions) ([]Book, error) { + return queryWithDB(options.db, connectDB, func(tx transaction) ([]Book, error) { + books := make([]Book, 0) + + if !options.includeBookmarks && !options.includeFolders { + return books, nil + } + + if err := validateFirst(options.first); err != nil { + return books, err + } + + if err := validateDirection(options.direction); err != nil { + return books, err + } + + if err := validateOrder(options.order); err != nil { + return books, err + } + + if err := validateQuery(options.query); err != nil { + return books, err + } + + return getBooksDB(tx, getBooksDBArgs{ + includeBooks: options.includeBookmarks, + includeFolders: options.includeFolders, + parentID: options.parentID, + query: options.query, + tags: options.tags, + after: options.after, + order: options.order, + direction: options.direction, + first: options.first, + }) + }) +} diff --git a/lib/list_tags.go b/lib/list_tags.go new file mode 100644 index 0000000..d276f8a --- /dev/null +++ b/lib/list_tags.go @@ -0,0 +1,68 @@ +package lib + +// listTagsOptions are the optional arguments for ListTags. +type listTagsOptions struct { + db NullString + query NullString + after NullString + direction Direction + first NullInt64 +} + +// DefaultListTagsOptions are the default options for ListTags. +func DefaultListTagsOptions() listTagsOptions { + return listTagsOptions{ + direction: DirectionAsc, + } +} + +// WithDB sets the location of the bookmarks database. +func (o *listTagsOptions) WithDB(db string) { + o.db = NullStringFrom(db) +} + +// WithQuery searches on tags. +func (o *listTagsOptions) WithQuery(query string) { + o.query = NullStringFrom(query) +} + +// WithAfter returns results after an ID. +func (o *listTagsOptions) WithAfter(after string) { + o.after = NullStringFrom(after) +} + +// WithDirection sets the direction to order by. +func (o *listTagsOptions) WithDirection(direction Direction) { + o.direction = direction +} + +// withFirst sets the max number of results to return. +func (o *listTagsOptions) WithFirst(first int64) { + o.first = NullInt64From(first) +} + +// ListTags lists tags in the bookmarks database. +func ListTags(options listTagsOptions) ([]string, error) { + return queryWithDB(options.db, connectDB, func(tx transaction) ([]string, error) { + tags := make([]string, 0) + + if err := validateFirst(options.first); err != nil { + return tags, err + } + + if err := validateDirection(options.direction); err != nil { + return tags, err + } + + if err := validateQuery(options.query); err != nil { + return tags, err + } + + return getTagsDB(tx, getTagsDBArgs{ + query: options.query, + after: options.after, + direction: options.direction, + first: options.first, + }) + }) +} diff --git a/lib/migrations/20231108125738_bookmarks.sql b/lib/migrations/20231108125738_bookmarks.sql new file mode 100644 index 0000000..24ae20b --- /dev/null +++ b/lib/migrations/20231108125738_bookmarks.sql @@ -0,0 +1,126 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE "bookmarks" ( + "id" TEXT PRIMARY KEY NOT NULL, + "parent_id" TEXT NULL, + "is_folder" INTEGER NOT NULL, + "name" TEXT NOT NULL CHECK(LENGTH("name") <= 2048), + "url" TEXT CHECK("url" IS NULL OR LENGTH("url") <= 2048), + "description" TEXT CHECK("description" IS NULL OR LENGTH("description") <= 4096), + "modified" TEXT NOT NULL DEFAULT(datetime()), + FOREIGN KEY("parent_id") REFERENCES "bookmarks"("id"), + CHECK ("is_folder" IN (0, 1)), + CHECK (("is_folder" = 0 AND "url" IS NOT NULL) OR ("is_folder" = 1 AND "url" IS NULL)) +) STRICT; + +CREATE TABLE "tags" ( + "tag" TEXT PRIMARY KEY NOT NULL CHECK(LENGTH("tag") <= 128), + "modified" TEXT NOT NULL DEFAULT(datetime()) +) STRICT; + +CREATE TABLE "bookmarks_tags" ( + "bookmark_id" TEXT NOT NULL, + "tag" TEXT NOT NULL, + "modified" TEXT NOT NULL DEFAULT(datetime()), + PRIMARY KEY ("bookmark_id", "tag"), + FOREIGN KEY("bookmark_id") REFERENCES "bookmarks"("id"), + FOREIGN KEY("tag") REFERENCES "tags"("tag") +) STRICT; + +CREATE VIRTUAL TABLE "bookmarks_fts" +USING fts5("id" UNINDEXED, "name", "url", "description", tokenize="trigram"); + +CREATE INDEX "ix_bookmarks_parent_id" +ON "bookmarks"("parent_id"); + +CREATE INDEX "ix_bookmarks_is_folder" +ON "bookmarks"("is_folder"); + +CREATE INDEX "ix_bookmarks_tags_bookmark_id" +ON "bookmarks_tags"("bookmark_id"); + +CREATE INDEX "ix_bookmarks_tags_tag" +ON "bookmarks_tags"("tag"); + +CREATE INDEX "ix_bookmarks_name" +ON "bookmarks"("name"); + +CREATE INDEX "ix_bookmarks_modified" +ON "bookmarks"("modifed"); + +CREATE TRIGGER "after_bookmarks_insert" AFTER INSERT ON "bookmarks" BEGIN + INSERT INTO bookmarks_fts ( + "id", + "name", + "url", + "description" + ) + VALUES( + new."id", + new."name", + new."url", + new."description" + ); +END; + +CREATE TRIGGER "after_bookmarks_update" UPDATE ON "bookmarks" BEGIN + UPDATE "bookmarks_fts" + SET "name" = new."name", + "url" = new."url", + "description" = new."description" + WHERE "id" = old."id"; +END; + +CREATE TRIGGER "after_bookmarks_delete" AFTER DELETE ON "bookmarks" BEGIN + DELETE FROM "bookmarks_fts" + WHERE "id" = old."id"; +END; + +CREATE VIRTUAL TABLE "tags_fts" +USING fts5("tag"); + +CREATE TRIGGER "after_tags_insert" AFTER INSERT ON "tags" BEGIN + INSERT INTO tags_fts ( + "tag" + ) + VALUES( + new."tag" + ); +END; + +CREATE TRIGGER "after_tags_delete" AFTER DELETE ON "tags" BEGIN + DELETE FROM "tags_fts" + WHERE "tag" = old."tag"; +END; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX "ix_bookmarks_parent_id"; + +DROP INDEX "ix_bookmarks_is_folder"; + +DROP INDEX ix_bookmarks_tags_bookmark_id; + +DROP INDEX "ix_bookmarks_tags_tag"; + +DROP INDEX "ix_bookmarks_name"; + +DROP INDEX "ix_bookmarks_modified"; + +DROP TRIGGER "after_bookmarks_insert"; + +DROP TRIGGER "after_bookmarks_update"; + +DROP TRIGGER "after_bookmarks_delete"; + +DROP TABLE "bookmarks_tags"; + +DROP TABLE "bookmarks_fts"; + +DROP TABLE "tags_fts"; + +DROP TABLE "bookmarks"; + +DROP TABLE "tags"; +-- +goose StatementEnd diff --git a/lib/null.go b/lib/null.go new file mode 100644 index 0000000..2960295 --- /dev/null +++ b/lib/null.go @@ -0,0 +1,147 @@ +package lib + +import ( + "database/sql" + "encoding/json" +) + +// NullString is a string that can be NULL. +type NullString struct { + sql.NullString // allows use with sql/database and grants null support (null if Valid = false) + Dirty bool // tracks if the argument has been provided at all (provided if Dirty = true) +} + +// MarshalJSON marshalls a NullString to JSON. +func (s NullString) MarshalJSON() ([]byte, error) { + if s.Valid { + return json.Marshal(s.String) + } + return []byte(`null`), nil +} + +// UnmarshalJSON unmarshalls a NullString from JSON. +func (s *NullString) UnmarshalJSON(data []byte) error { + if string(data) == `null` { + s.Valid = false + return nil + } + + s.Valid = true + return json.Unmarshal(data, &s.String) +} + +// NullStringFrom converts a string to a NullString. +// The returned NullString will have Dirty set to true. +func NullStringFrom(str string) NullString { + return NullString{ + NullString: sql.NullString{ + Valid: true, + String: str, + }, + Dirty: true, + } +} + +// NullStringFromPtr converts a *string to a NullString. +// If the string is nil it will be treated as null. +// The returned NullString will have Dirty set to true. +func NullStringFromPtr(str *string) NullString { + if str == nil { + return NullString{ + NullString: sql.NullString{ + Valid: false, + String: "", + }, + Dirty: true, + } + } + + return NullString{ + NullString: sql.NullString{ + Valid: true, + String: *str, + }, + Dirty: true, + } +} + +// PtrFromNullString converts a NullString to a *string. +// If the NullString is not Valid nil is returned. +func PtrFromNullString(str NullString) *string { + if str.Valid { + return &str.String + } + + return nil +} + +// NullInt64 is an int64 that can be NULL. +type NullInt64 struct { + sql.NullInt64 // allows use with sql/database and grants null support (null if Valid = false) + Dirty bool // tracks if the argument has been provided at all (provided if Dirty = true) +} + +// MarshalJSON marshalls a NullInt64 to JSON. +func (s NullInt64) MarshalJSON() ([]byte, error) { + if s.Valid { + return json.Marshal(s.Int64) + } + return []byte(`null`), nil +} + +// UnmarshalJSON unmarshalls a NullInt64 from JSON. +func (s *NullInt64) UnmarshalJSON(data []byte) error { + if string(data) == `null` { + s.Valid = false + return nil + } + + s.Valid = true + return json.Unmarshal(data, &s.Int64) +} + +// NullInt64From converts an int to a NullInt. +// If the int is zero it will be treated as null. +// The returned NullInt64 will have Dirty set to true. +func NullInt64From(num int64) NullInt64 { + return NullInt64{ + NullInt64: sql.NullInt64{ + Valid: true, + Int64: num, + }, + Dirty: true, + } +} + +// NullInt64FromPtr converts a *int64 to a NullInt64. +// If the int64 is nil it will be treated as null. +// The returned NullInt64 will have Dirty set to true. +func NullInt64FromPtr(num *int64) NullInt64 { + if num == nil { + return NullInt64{ + NullInt64: sql.NullInt64{ + Valid: false, + Int64: 0, + }, + Dirty: true, + } + } + + return NullInt64{ + NullInt64: sql.NullInt64{ + Valid: true, + Int64: *num, + }, + Dirty: true, + } +} + +// PtrFromNullInt64 converts a NullInt64 to a *int64. +// If the NullInt64 is not Valid nil is returned. +func PtrFromNullInt64(num NullInt64) *int64 { + if num.Valid { + return &num.Int64 + } + + return nil +} diff --git a/lib/null_test.go b/lib/null_test.go new file mode 100644 index 0000000..c72daf4 --- /dev/null +++ b/lib/null_test.go @@ -0,0 +1,258 @@ +package lib + +import ( + "database/sql" + "fmt" + "reflect" + "testing" +) + +func TestNullStringMarshallJSON(t *testing.T) { + type test struct { + input NullString + want string + } + + tests := []test{ + {input: NullStringFrom("string"), want: `"string"`}, + {input: NullStringFromPtr(nil), want: `null`}, + } + + for _, tc := range tests { + t.Run(fmt.Sprintf("%+v", tc.input), func(t *testing.T) { + got, err := tc.input.MarshalJSON() + if err != nil { + t.Errorf("unexpected error: %+v", err) + } + if string(got) != tc.want { + t.Errorf("got %+v; want %+v", string(got), tc.want) + } + }) + } +} + +func TestNullStringUnmarshallJSON(t *testing.T) { + type test struct { + input string + want NullString + } + + tests := []test{ + {input: `null`, want: NullStringFromPtr(nil)}, + {input: `"string"`, want: NullStringFrom("string")}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + got := NullStringFromPtr(nil) + err := got.UnmarshalJSON([]byte(tc.input)) + if err != nil { + t.Errorf("unexpected error: %+v", err) + } + equal := reflect.DeepEqual(got, tc.want) + if !equal { + t.Errorf("got %+v; want %+v", got, tc.want) + } + }) + } +} + +func TestNullStringFrom(t *testing.T) { + got := NullStringFrom("test") + want := NullString{ + NullString: sql.NullString{ + Valid: true, + String: "test", + }, + Dirty: true, + } + + equal := reflect.DeepEqual(got, want) + if !equal { + t.Errorf("got %+v; want %+v", got, want) + } +} + +func TestNullStringFromPtr(t *testing.T) { + type test struct { + input *string + want NullString + } + + input := "string" + tests := []test{ + {input: nil, want: NullString{ + NullString: sql.NullString{ + Valid: false, + String: "", + }, + Dirty: true, + }}, + {input: &input, want: NullString{ + NullString: sql.NullString{ + Valid: true, + String: input, + }, + Dirty: true, + }}, + } + + for _, tc := range tests { + t.Run(fmt.Sprintf("%+v", tc.input), func(t *testing.T) { + got := NullStringFromPtr(tc.input) + equal := reflect.DeepEqual(got, tc.want) + if !equal { + t.Errorf("got %+v; want %+v", got, tc.want) + } + }) + } +} + +func TestPtrFromNullString(t *testing.T) { + type test struct { + input NullString + want *string + } + + input := "string" + tests := []test{ + {input: NullStringFrom("string"), want: &input}, + {input: NullStringFromPtr(nil), want: nil}, + } + + for _, tc := range tests { + t.Run(fmt.Sprintf("%+v", tc.input), func(t *testing.T) { + got := PtrFromNullString(tc.input) + if (got == nil || tc.want == nil) && (got != nil || tc.want != nil) { + t.Errorf("got %+v; want %+v", got, tc.want) + } else if got != nil && tc.want != nil && *got != *tc.want { + t.Errorf("got %+v; want %+v", *got, *tc.want) + } + }) + } +} + +func TestNullInt64MarshallJSON(t *testing.T) { + type test struct { + input NullInt64 + want string + } + + tests := []test{ + {input: NullInt64From(1), want: `1`}, + {input: NullInt64FromPtr(nil), want: `null`}, + } + + for _, tc := range tests { + t.Run(fmt.Sprintf("%+v", tc.input), func(t *testing.T) { + got, err := tc.input.MarshalJSON() + if err != nil { + t.Errorf("unexpected error: %+v", err) + } + if string(got) != tc.want { + t.Errorf("got %+v; want %+v", string(got), tc.want) + } + }) + } +} + +func TestNullInt64UnmarshallJSON(t *testing.T) { + type test struct { + input string + want NullInt64 + } + + tests := []test{ + {input: `null`, want: NullInt64FromPtr(nil)}, + {input: `1`, want: NullInt64From(1)}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + got := NullInt64FromPtr(nil) + err := got.UnmarshalJSON([]byte(tc.input)) + if err != nil { + t.Errorf("unexpected error: %+v", err) + } + equal := reflect.DeepEqual(got, tc.want) + if !equal { + t.Errorf("got %+v; want %+v", got, tc.want) + } + }) + } +} + +func TestNullInt64From(t *testing.T) { + got := NullInt64From(1) + want := NullInt64{ + NullInt64: sql.NullInt64{ + Valid: true, + Int64: 1, + }, + Dirty: true, + } + + equal := reflect.DeepEqual(got, want) + if !equal { + t.Errorf("got %+v; want %+v", got, want) + } +} + +func TestNullInt64FromPtr(t *testing.T) { + type test struct { + input *int64 + want NullInt64 + } + + var input int64 = 1 + tests := []test{ + {input: nil, want: NullInt64{ + NullInt64: sql.NullInt64{ + Valid: false, + Int64: 0, + }, + Dirty: true, + }}, + {input: &input, want: NullInt64{ + NullInt64: sql.NullInt64{ + Valid: true, + Int64: input, + }, + Dirty: true, + }}, + } + + for _, tc := range tests { + t.Run(fmt.Sprintf("%+v", tc.input), func(t *testing.T) { + got := NullInt64FromPtr(tc.input) + equal := reflect.DeepEqual(got, tc.want) + if !equal { + t.Errorf("got %+v; want %+v", got, tc.want) + } + }) + } +} + +func TestPtrFromNullInt64(t *testing.T) { + type test struct { + input NullInt64 + want *int64 + } + + var input int64 = 1 + tests := []test{ + {input: NullInt64From(1), want: &input}, + {input: NullInt64FromPtr(nil), want: nil}, + } + + for _, tc := range tests { + t.Run(fmt.Sprintf("%+v", tc.input), func(t *testing.T) { + got := PtrFromNullInt64(tc.input) + if (got == nil || tc.want == nil) && (got != nil || tc.want != nil) { + t.Errorf("got %+v; want %+v", got, tc.want) + } else if got != nil && tc.want != nil && *got != *tc.want { + t.Errorf("got %+v; want %+v", *got, *tc.want) + } + }) + } +} diff --git a/lib/order.go b/lib/order.go new file mode 100644 index 0000000..8f89e22 --- /dev/null +++ b/lib/order.go @@ -0,0 +1,9 @@ +package lib + +// Order is the field results are ordered on. +type Order string + +const ( + OrderModified Order = "modified" + OrderName Order = "name" +) diff --git a/lib/remove_book.go b/lib/remove_book.go new file mode 100644 index 0000000..d61f3a7 --- /dev/null +++ b/lib/remove_book.go @@ -0,0 +1,48 @@ +package lib + +// removeBookOptions are the optional arguments for RemoveBook. +type removeBookOptions struct { + db NullString +} + +// DefaultRemoveBookOptions are the default options for RemoveBook. +func DefaultRemoveBookOptions() removeBookOptions { + return removeBookOptions{} +} + +// WithDB sets the location of the bookmarks database. +func (o *removeBookOptions) WithDB(db string) { + o.db = NullStringFrom(db) +} + +// RemoveBook removes a bookmark from the bookmarks database. +func RemoveBook(id string, options removeBookOptions) (err error) { + return execWithTransaction(options.db, connectDB, func(tx transaction) error { + if err := validateBookID(tx, id); err != nil { + return err + } + + books, err := getBooksDB(tx, getBooksDBArgs{ + idFilter: id, + includeBooks: true, + }) + if err != nil { + return err + } + book := books[0] + + if err = unlinkTagsDB(tx, book.ID, book.Tags); err != nil { + return err + } + + if err = removeBookDB(tx, book.ID); err != nil { + return err + } + + if err = cleanOrphanedTagsDB(tx, book.Tags); err != nil { + return err + } + + return nil + }) +} diff --git a/lib/remove_folder.go b/lib/remove_folder.go new file mode 100644 index 0000000..d44c7f9 --- /dev/null +++ b/lib/remove_folder.go @@ -0,0 +1,56 @@ +package lib + +import ( + "github.com/samber/lo" +) + +// removeFolderOptions are the optional arguments for RemoveFolder. +type removeFolderOptions struct { + db NullString +} + +// DefaultRemoveFolderOptions are the default options for RemoveFolder. +func DefaultRemoveFolderOptions() removeFolderOptions { + return removeFolderOptions{} +} + +// WithDB sets the location of the bookmarks database. +func (o *removeFolderOptions) WithDB(db string) { + o.db = NullStringFrom(db) +} + +// RemoveFolder removes a folder from the bookmarks database. +func RemoveFolder(id string, options removeFolderOptions) error { + return execWithTransaction(options.db, connectDB, func(tx transaction) error { + if err := validateParentID(tx, NullStringFrom(id)); err != nil { + return err + } + + bookOrFolders, err := getParentAndChildren(tx, id) + if err != nil { + return err + } + + for _, bookOrFolder := range lo.Reverse(bookOrFolders) { + if !bookOrFolder.IsFolder { + if err = unlinkTagsDB(tx, bookOrFolder.ID, bookOrFolder.Tags); err != nil { + return err + } + + if err = removeBookDB(tx, bookOrFolder.ID); err != nil { + return err + } + + if err = cleanOrphanedTagsDB(tx, bookOrFolder.Tags); err != nil { + return err + } + } else { + if err = removeFolderDB(tx, bookOrFolder.ID); err != nil { + return err + } + } + } + + return nil + }) +} diff --git a/lib/remove_tags.go b/lib/remove_tags.go new file mode 100644 index 0000000..5744865 --- /dev/null +++ b/lib/remove_tags.go @@ -0,0 +1,63 @@ +package lib + +import ( + "github.com/samber/lo" +) + +// removeTagsOptions are the optional arguments for RemoveTags. +type removeTagsOptions struct { + db NullString +} + +// DefaultRemoveTagsOptions are the default options for RemoveTags. +func DefaultRemoveTagsOptions() removeTagsOptions { + return removeTagsOptions{} +} + +// WithDB sets the location of the bookmarks database. +func (o *removeTagsOptions) WithDB(db string) { + o.db = NullStringFrom(db) +} + +// RemoveTags removes tags from a bookmark in the bookmarks database. +func RemoveTags(id string, tags []string, options removeTagsOptions) (Book, error) { + return queryWithTransaction(options.db, connectDB, func(tx transaction) (Book, error) { + var book Book + + books, err := getBooksDB(tx, getBooksDBArgs{ + idFilter: id, + includeBooks: true, + }) + if err != nil { + return book, err + } + + if len(books) != 1 || books[0].IsFolder { + return book, ErrBookNotFound + } + + for _, tag := range tags { + if !lo.Contains(books[0].Tags, tag) { + return book, ErrTagNotFound + } + } + + if err = unlinkTagsDB(tx, books[0].ID, tags); err != nil { + return book, err + } + + if err = cleanOrphanedTagsDB(tx, tags); err != nil { + return book, err + } + + books, err = getBooksDB(tx, getBooksDBArgs{ + idFilter: id, + includeBooks: true, + }) + if err != nil { + return book, err + } + + return books[0], nil + }) +} diff --git a/lib/transaction.go b/lib/transaction.go new file mode 100644 index 0000000..620a5db --- /dev/null +++ b/lib/transaction.go @@ -0,0 +1,275 @@ +package lib + +import ( + "database/sql" + "embed" + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/blockloop/scan/v2" + _ "github.com/mattn/go-sqlite3" + "github.com/nullism/bqb" + "github.com/pressly/goose/v3" +) + +// The abstractions in this file simplify working with transactions in Go. + +//go:embed migrations/*.sql +var embedMigrations embed.FS + +// transaction represents a database/sql connection. +type transaction interface { + Exec(query string, args ...interface{}) (sql.Result, error) + Query(query string, args ...interface{}) (*sql.Rows, error) + QueryRow(query string, args ...interface{}) *sql.Row +} + +// queryTxFn is a function that operates on a transaction which returns results. +type queryTxFn[T any] func(transaction) (T, error) + +// queryWithTransaction creates a scope for functions that operate on a transaction which returns results. +// Handles connecting to the DB, creating the transaction, committing/rolling back, and closing the connection. +// Will also handle creating the DB if it doesn't exist and applying missing migrations to it. +func queryWithTransaction[T any](dbFile NullString, connectFn connectDBFn, queryFn queryTxFn[T]) (val T, err error) { + db, err := connectFn(dbFile) + if err != nil { + return + } + defer db.Close() + + tx, err := db.Begin() + if err != nil { + err = fmt.Errorf("%w: %w", ErrUnexpected, err) + return + } + + defer func() { + if p := recover(); p != nil { + // a panic occurred, rollback and repanic + //nolint:errcheck + tx.Rollback() + panic(p) + } else if err != nil { + // something went wrong, rollback + //nolint:errcheck + tx.Rollback() + } else { + // all good, commit + err = tx.Commit() + if err != nil { + err = fmt.Errorf("%w: %w", ErrUnexpected, err) + } + } + }() + + val, err = queryFn(tx) + return val, err +} + +// QueryTxFn is a function that operates on a transaction which doesn't return results. +type execTxFn func(transaction) error + +// execWithTransaction creates a scope for functions that operate on a transaction which doesn't return results. +// Handles connecting to the DB, creating the transaction, committing/rolling back, and closing the connection. +// Will also handle creating the DB if it doesn't exist and applying missing migrations to it. +func execWithTransaction(dbFile NullString, connectFn connectDBFn, execFn execTxFn) (err error) { + db, err := connectFn(dbFile) + if err != nil { + return + } + defer db.Close() + + tx, err := db.Begin() + if err != nil { + err = fmt.Errorf("%w: %w", ErrUnexpected, err) + return + } + + defer func() { + if p := recover(); p != nil { + // a panic occurred, rollback and repanic + //nolint:errcheck + tx.Rollback() + panic(p) + } else if err != nil { + // something went wrong, rollback + //nolint:errcheck + tx.Rollback() + } else { + // all good, commit + err = tx.Commit() + if err != nil { + err = fmt.Errorf("%w: %w", ErrUnexpected, err) + } + } + }() + + err = execFn(tx) + return err +} + +// queryWithDB creates a scope for for functions that operate on a database connection which return results. +// Handles connecting to the DB and closing the connection. +// Will also handle creating the DB if it doesn't exist and applying missing migrations to it. +func queryWithDB[T any](dbFile NullString, connectFn connectDBFn, queryFn queryTxFn[T]) (T, error) { + var val T + + db, err := connectFn(dbFile) + if err != nil { + return val, err + } + defer db.Close() + + return queryFn(db) +} + +// connectDBFn is a function that connects to SQLite database. +type connectDBFn func(dbFile NullString) (*sql.DB, error) + +// connectDB returns a connection to the bookmarks database. +func connectDB(dbFile NullString) (*sql.DB, error) { + dbLocation, err := getDBLocation(dbFile, runtime.GOOS, os.MkdirAll, os.UserHomeDir, filepath.Join) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrUnexpected, err) + } + + db, err := sql.Open("sqlite3", dbLocation) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrUnexpected, err) + } + + if err := configureDB(db); err != nil { + return nil, fmt.Errorf("%w: %w", ErrUnexpected, err) + } + + if err := migrateDB(db); err != nil { + return nil, fmt.Errorf("%w: %w", ErrUnexpected, err) + } + + return db, nil +} + +// mkDirAllFn creates a directory if it doesn't alread exist. +type mkDirAllFn func(path string, perm os.FileMode) error + +// userHomeFn returns the home directory of the current user. +type userHomeFn func() (string, error) + +// joinFn joins path segments together. +type joinFn func(elem ...string) string + +// getDBLocation returns the default location for the bookmarks database. +func getDBLocation(dbFile NullString, goos string, mkDirAll mkDirAllFn, userHome userHomeFn, join joinFn) (string, error) { + if !dbFile.Valid || !dbFile.Dirty { + home, err := userHome() + if err != nil { + return "", err + } + + var folder string + if goos == "linux" { + folder = join(home, ".armaria") + } else if goos == "windows" { + folder = join(home, "AppData", "Local", "Armaria") + } else if goos == "darwin" { + folder = join(home, "Library", "Application Support", "Armaria") + } else { + panic("Unsupported operating system") + } + + err = mkDirAll(folder, os.ModePerm) + if err != nil { + return "", err + } + + return filepath.Join(folder, "bookmarks.db"), nil + } else { + return dbFile.String, nil + } +} + +// configureDB uses PRAGMA to configure SQLite: +// - Use a WAL for journal mode +// - Enforce foreign keys. +func configureDB(db *sql.DB) error { + if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { + return err + } + + if _, err := db.Exec("PRAGMA foreign_keys=on"); err != nil { + return err + } + + return nil +} + +// migrateDB brings the database up to the latest migration. +func migrateDB(db *sql.DB) error { + goose.SetBaseFS(embedMigrations) + goose.SetLogger(goose.NopLogger()) + + if err := goose.SetDialect("sqlite3"); err != nil { + return err + } + + if err := goose.Up(db, "migrations"); err != nil { + return err + } + + return nil +} + +// query runs SQL that returns results. +func query[T any](tx transaction, q *bqb.Query) ([]T, error) { + var results []T + + sql, args, err := q.ToSql() + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrUnexpected, err) + } + + rows, err := tx.Query(sql, args...) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrUnexpected, err) + } + + defer rows.Close() + + err = scan.RowsStrict(&results, rows) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrUnexpected, err) + } + + return results, nil +} + +// exec runs SQL that does't return results. +func exec(tx transaction, q *bqb.Query) error { + sql, args, err := q.ToSql() + if err != nil { + return fmt.Errorf("%w: %w", ErrUnexpected, err) + } + + if _, err = tx.Exec(sql, args...); err != nil { + return fmt.Errorf("%w: %w", ErrUnexpected, err) + } + + return nil +} + +// count runs SQL that returns a count. +func count(tx transaction, q *bqb.Query) (int, error) { + sql, args, err := q.ToSql() + if err != nil { + return 0, fmt.Errorf("%w: %w", ErrUnexpected, err) + } + + var count = 0 + if err = tx.QueryRow(sql, args...).Scan(&count); err != nil { + return 0, fmt.Errorf("%w: %w", ErrUnexpected, err) + } + + return count, nil +} diff --git a/lib/transaction_test.go b/lib/transaction_test.go new file mode 100644 index 0000000..3b6018b --- /dev/null +++ b/lib/transaction_test.go @@ -0,0 +1,326 @@ +package lib + +import ( + "database/sql" + "errors" + "os" + "path" + "testing" + + "gopkg.in/DATA-DOG/go-sqlmock.v1" +) + +/// queryWithTransactionCommitsTransaction + +func TestQueryWithTransactionCommitsTransactionIfNoError(t *testing.T) { + want := "value" + + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("unexpected error: %+v", err) + } + + mock.ExpectBegin() + mock.ExpectCommit() + mock.ExpectClose() + + got, err := queryWithTransaction[string]( + NullStringFrom("bookmarks.db"), + func(dbFile NullString) (*sql.DB, error) { + return db, nil + }, + func(tx transaction) (string, error) { + return want, nil + }, + ) + + if err != nil { + t.Fatalf("unexpected error: %+v", err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expections: %s", err) + } + + if got != want { + t.Errorf("got %+v; want %+v", got, want) + } +} + +func TestQueryWithTransactionRollsBackTransactionIfError(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("unexpected error: %+v", err) + } + + mock.ExpectBegin() + mock.ExpectRollback() + mock.ExpectClose() + + _, err = queryWithTransaction[string]( + NullStringFrom("bookmarks.db"), + func(dbFile NullString) (*sql.DB, error) { + return db, nil + }, + func(tx transaction) (string, error) { + return "", errors.New("test") + }, + ) + + if err == nil { + t.Fatalf("unexpected success") + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expections: %s", err) + } +} + +func TestQueryWithTransactionRollsBackTransactionIfPanic(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("unexpected error: %+v", err) + } + + mock.ExpectBegin() + mock.ExpectRollback() + mock.ExpectClose() + + defer func() { + _ = recover() + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expections: %s", err) + } + }() + + _, _ = queryWithTransaction[string]( + NullStringFrom("bookmarks.db"), + func(dbFile NullString) (*sql.DB, error) { + return db, nil + }, + func(tx transaction) (string, error) { + panic("") + }, + ) + + t.Errorf("did not panic") +} + +// execWithTransaction + +func TestExecWithTransactionCommitsTransactionIfNoError(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("unexpected error: %+v", err) + } + + mock.ExpectBegin() + mock.ExpectCommit() + mock.ExpectClose() + + err = execWithTransaction( + NullStringFrom("bookmarks.db"), + func(dbFile NullString) (*sql.DB, error) { + return db, nil + }, + func(tx transaction) error { + return nil + }, + ) + + if err != nil { + t.Fatalf("unexpected error: %+v", err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expections: %s", err) + } +} + +func TestExecWithTransactionRollsBackTransactionIfError(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("unexpected error: %+v", err) + } + + mock.ExpectBegin() + mock.ExpectRollback() + mock.ExpectClose() + + err = execWithTransaction( + NullStringFrom("bookmarks.db"), + func(dbFile NullString) (*sql.DB, error) { + return db, nil + }, + func(tx transaction) error { + return errors.New("test") + }, + ) + + if err == nil { + t.Fatalf("unexpected success") + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expections: %s", err) + } +} + +func TestExecWithTransactionRollsBackTransactionIfPanic(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("unexpected error: %+v", err) + } + + mock.ExpectBegin() + mock.ExpectRollback() + mock.ExpectClose() + + defer func() { + _ = recover() + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expections: %s", err) + } + }() + + _ = execWithTransaction( + NullStringFrom("bookmarks.db"), + func(dbFile NullString) (*sql.DB, error) { + return db, nil + }, + func(tx transaction) error { + panic("") + }, + ) + + t.Errorf("did not panic") +} + +// queryWithDB + +func TestQueryWithDB(t *testing.T) { + want := "value" + + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("unexpected error: %+v", err) + } + + mock.ExpectClose() + + got, err := queryWithDB[string]( + NullStringFrom("bookmarks.db"), + func(dbFile NullString) (*sql.DB, error) { + return db, nil + }, + func(tx transaction) (string, error) { + return want, nil + }, + ) + + if err != nil { + t.Fatalf("unexpected error: %+v", err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expections: %s", err) + } + + if got != want { + t.Errorf("got %+v; want %+v", got, want) + } +} + +// getDefaultDB + +func TestGetDefaultDB(t *testing.T) { + type test struct { + input NullString + goos string + folder string + db string + folderCreated bool + } + + tests := []test{ + { + input: NullStringFromPtr(nil), + goos: "windows", + folder: "~/AppData/Local/Armaria", + db: "~/AppData/Local/Armaria/bookmarks.db", + folderCreated: true, + }, + { + input: NullStringFromPtr(nil), + goos: "linux", + folder: "~/.armaria", + db: "~/.armaria/bookmarks.db", + folderCreated: true, + }, + { + input: NullStringFromPtr(nil), + goos: "darwin", + folder: "~/Library/Application Support/Armaria", + db: "~/Library/Application Support/Armaria/bookmarks.db", + folderCreated: true, + }, + { + input: NullStringFrom("bookmarks.db"), + db: "bookmarks.db", + folderCreated: false, + }, + } + + userHome := func() (string, error) { + return "~", nil + } + + for _, tc := range tests { + t.Run(tc.goos, func(t *testing.T) { + folderCreated := false + mkDirAll := func(path string, perm os.FileMode) error { + folderCreated = true + if path != tc.folder { + t.Errorf("folder: got %+v; want %+v", path, tc.folder) + } + + return nil + } + + got, err := getDBLocation(tc.input, tc.goos, mkDirAll, userHome, path.Join) + if err != nil { + t.Fatalf("unexpected error: %+v", err) + } + + if folderCreated != tc.folderCreated { + t.Fatalf("folder created: got %+v; want %+v", folderCreated, tc.folderCreated) + } + + if got != tc.db { + t.Errorf("db: got %+v; want %+v", got, tc.db) + } + }) + } +} + +// configureDB + +func TestConfigureDB(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("unexpected error: %+v", err) + } + + mock.ExpectExec("PRAGMA journal_mode=WAL").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("PRAGMA foreign_keys=on").WillReturnResult(sqlmock.NewResult(0, 0)) + + if err := configureDB(db); err != nil { + t.Fatalf("unexpected error: %+v", err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expections: %s", err) + } +} diff --git a/lib/update_book.go b/lib/update_book.go new file mode 100644 index 0000000..d9624c3 --- /dev/null +++ b/lib/update_book.go @@ -0,0 +1,108 @@ +package lib + +// updateBookOptions are the optional arguments for UpdateBook. +type updateBookOptions struct { + db NullString + name NullString + url NullString + description NullString + parentID NullString +} + +// DefaultUpdateBookOptions are the default options for UpdateBook. +func DefaultUpdateBookOptions() updateBookOptions { + return updateBookOptions{} +} + +// WithDB sets the location of the bookmarks database. +func (o *updateBookOptions) WithDB(db string) { + o.db = NullStringFrom(db) +} + +// WithName updates the name of a bookmark. +func (o *updateBookOptions) WithName(name string) { + o.name = NullStringFrom(name) +} + +// WithURL updates the URL of a bookmark. +func (o *updateBookOptions) WithURL(url string) { + o.url = NullStringFrom(url) +} + +// WithDescription updates the description of a bookmark. +func (o *updateBookOptions) WithDescription(description string) { + o.description = NullStringFrom(description) +} + +// WithParentID updates the parentID of a bookmark. +func (o *updateBookOptions) WithParentID(parentID string) { + o.parentID = NullStringFrom(parentID) +} + +// WithoutDescription removes the description of a bookmark. +func (o *updateBookOptions) WithoutDescription() { + o.description = NullStringFromPtr(nil) +} + +// WithoutParentID removes the parent ID of a bookmark. +func (o *updateBookOptions) WithoutParentID() { + o.parentID = NullStringFromPtr(nil) +} + +// UpdateBook updates a bookmark in the bookmarks database. +func UpdateBook(id string, options updateBookOptions) (Book, error) { + return queryWithTransaction(options.db, connectDB, func(tx transaction) (Book, error) { + var book Book + + if err := validateBookID(tx, id); err != nil { + return book, err + } + + if !options.name.Dirty && !options.url.Dirty && !options.description.Dirty && !options.parentID.Dirty { + return book, ErrNoUpdate + } + + if options.name.Dirty { + if err := validateName(options.name); err != nil { + return book, err + } + } + + if options.url.Dirty { + if err := validateURL(options.url); err != nil { + return book, err + } + } + + if options.description.Dirty { + if err := validateDescription(options.description); err != nil { + return book, err + } + } + + if options.parentID.Dirty { + if err := validateParentID(tx, options.parentID); err != nil { + return book, err + } + } + + if err := updateBookDB(tx, id, updateBookDBArgs{ + name: options.name, + url: options.url, + description: options.description, + parentID: options.parentID, + }); err != nil { + return book, err + } + + books, err := getBooksDB(tx, getBooksDBArgs{ + idFilter: id, + includeBooks: true, + }) + if err != nil { + return book, err + } + + return books[0], nil + }) +} diff --git a/lib/update_folder.go b/lib/update_folder.go new file mode 100644 index 0000000..f52245a --- /dev/null +++ b/lib/update_folder.go @@ -0,0 +1,77 @@ +package lib + +// updateFolderOptions are the optional arguments for UpdateFolder. +type updateFolderOptions struct { + db NullString + name NullString + parentID NullString +} + +// DefaultUpdateFolderOptions are the default options for UpdateFolder. +func DefaultUpdateFolderOptions() updateFolderOptions { + return updateFolderOptions{} +} + +// WithDB sets the location of the bookmarks database. +func (o *updateFolderOptions) WithDB(db string) { + o.db = NullStringFrom(db) +} + +// WithName updates the name of a folder. +func (o *updateFolderOptions) WithName(name string) { + o.name = NullStringFrom(name) +} + +// WithParentID updates the parentID of a folder. +func (o *updateFolderOptions) WithParentID(parentID string) { + o.parentID = NullStringFrom(parentID) +} + +// WithoutParentID removes the parent ID of a folder. +func (o *updateFolderOptions) WithoutParentID() { + o.parentID = NullStringFromPtr(nil) +} + +// UpdateFolder updates a folder in the bookmarks database. +func UpdateFolder(id string, options updateFolderOptions) (Book, error) { + return queryWithTransaction(options.db, connectDB, func(tx transaction) (Book, error) { + var book Book + + if err := validateParentID(tx, NullStringFrom(id)); err != nil { + return book, err + } + + if !options.name.Dirty && !options.parentID.Dirty { + return book, ErrNoUpdate + } + + if options.name.Dirty { + if err := validateName(options.name); err != nil { + return book, err + } + } + + if options.parentID.Dirty { + if err := validateParentID(tx, options.parentID); err != nil { + return book, err + } + } + + if err := updateFolderDB(tx, id, updateFolderDBArgs{ + name: options.name, + parentID: options.parentID, + }); err != nil { + return book, err + } + + books, err := getBooksDB(tx, getBooksDBArgs{ + idFilter: id, + includeFolders: true, + }) + if err != nil { + return book, err + } + + return books[0], nil + }) +} diff --git a/lib/validators.go b/lib/validators.go new file mode 100644 index 0000000..a302f09 --- /dev/null +++ b/lib/validators.go @@ -0,0 +1,186 @@ +package lib + +import ( + "fmt" + "regexp" + + "github.com/samber/lo" +) + +// This file contains validators that should be run on user inputs before any DB calls are made. + +const MaxNameLength = 2048 +const MaxURLLength = 2048 +const MaxDescriptionLength = 4096 +const MaxTagLength = 128 +const MaxTags = 24 +const MinQueryLength = 3 + +// validateURL validates a URL value. +// URL is only used for bookmarks and must have a length >= 1 and <= 2048. +func validateURL(URL NullString) error { + if !URL.Valid || URL.String == "" { + return ErrURLTooShort + } + + if len(URL.String) > MaxURLLength { + return ErrURLTooLong + } + + return nil +} + +// validateName validates a name value. +// Description is required and must have a length >= 1 and <= 1024. +func validateName(name NullString) error { + if !name.Valid || name.String == "" { + return ErrNameTooShort + } + + if len(name.String) > MaxNameLength { + return ErrNameTooLong + } + + return nil +} + +// validateDescription validates a description value. +// Description is optional and must have a length <= 5096. +func validateDescription(description NullString) error { + if !description.Valid { + return nil + } + + if description.String == "" { + return ErrDescriptionTooShort + } + + if len(description.String) > MaxDescriptionLength { + return ErrDescriptionTooLong + } + + return nil +} + +// validateTag validates a tags value. +// Tags must be unique. +// Each must have a length >= 1 and <= 128. +// Tags can have the chars A-Z a-z 0-9 - _ +func validateTags(tags []string, existingTags []string) error { + if len(tags)+len(existingTags) > 24 { + return ErrTooManyTags + } + + if len(tags) != len(lo.Uniq(tags)) { + return ErrDuplicateTag + } + + r, e := regexp.Compile(`^[a-zA-Z0-9\-_]*$`) + if e != nil { + return e + } + + for _, tag := range tags { + if tag == "" { + return ErrTagTooShort + } + + if len(tag) > MaxTagLength { + return ErrTagTooLong + } + + matched := r.MatchString(tag) + if !matched { + return ErrTagInvalidChar + } + + if lo.Contains(existingTags, tag) { + return ErrDuplicateTag + } + } + + return nil +} + +// validateFirst validates a limit value. +// Limit is optional, but if it is provided it must be > 0. +func validateFirst(limit NullInt64) error { + if !limit.Valid { + return nil + } + + if limit.Int64 <= 0 { + return ErrFirstTooSmall + } + + return nil +} + +// validateOrder validates an order value. +// Order must be modified or name. +func validateOrder(order Order) error { + if order != OrderModified && order != OrderName { + return ErrInvalidOrder + } + + return nil +} + +// validateDirection validates a direction value. +// Direction must be asc or desc. +func validateDirection(direction Direction) error { + if direction != DirectionAsc && direction != DirectionDesc { + return ErrInvalidDirection + } + + return nil + +} + +// validateQuery validates a query value. +// Query is optional must be at least 3 chars long. +func validateQuery(query NullString) error { + if !query.Valid { + return nil + } + + if len(query.String) < MinQueryLength { + return ErrQueryTooShort + } + + return nil +} + +// validateBookID validates a bookmark ID value. +// The target bookmark must exist. +func validateBookID(tx transaction, ID string) error { + exists, err := bookFolderExistsDB(tx, ID, false) + if err != nil { + return fmt.Errorf("%w: %w", ErrUnexpected, err) + } + + if !exists { + return ErrBookNotFound + } + + return nil +} + +// validateParentID validates a parent ID value. +// Parent ID is optional but if it is provided the target parent folder must exist. +func validateParentID(tx transaction, parentID NullString) error { + if !parentID.Valid { + return nil + } + + exists, err := bookFolderExistsDB(tx, parentID.String, true) + if err != nil { + return fmt.Errorf("%w: %w", ErrUnexpected, err) + } + + if !exists { + return ErrFolderNotFound + } + + return nil +} diff --git a/lib/validators_test.go b/lib/validators_test.go new file mode 100644 index 0000000..2bcd99e --- /dev/null +++ b/lib/validators_test.go @@ -0,0 +1,239 @@ +package lib + +import ( + "database/sql" + "errors" + "fmt" + "github.com/google/uuid" + "testing" +) + +func TestValidateURL(t *testing.T) { + type test struct { + input NullString + want error + } + + tests := []test{ + {input: nullStringOfLength(0, true), want: ErrURLTooShort}, + {input: nullStringOfLength(0, false), want: ErrURLTooShort}, + {input: nullStringOfLength(1, false), want: nil}, + {input: nullStringOfLength(2049, false), want: ErrURLTooLong}, + {input: nullStringOfLength(2048, false), want: nil}, + } + + for _, tc := range tests { + t.Run(tc.input.String, func(t *testing.T) { + got := validateURL(tc.input) + validateValidator(t, tc.want, got) + }) + } +} + +func TestValidateName(t *testing.T) { + type test struct { + input NullString + want error + } + + tests := []test{ + {input: nullStringOfLength(0, true), want: ErrNameTooShort}, + {input: nullStringOfLength(0, false), want: ErrNameTooShort}, + {input: nullStringOfLength(1, false), want: nil}, + {input: nullStringOfLength(2049, false), want: ErrNameTooLong}, + {input: nullStringOfLength(2048, false), want: nil}, + } + + for _, tc := range tests { + t.Run(tc.input.String, func(t *testing.T) { + got := validateName(tc.input) + validateValidator(t, tc.want, got) + }) + } +} + +func TestValidateDescription(t *testing.T) { + type test struct { + input NullString + want error + } + + tests := []test{ + {input: nullStringOfLength(0, true), want: nil}, + {input: nullStringOfLength(0, false), want: ErrDescriptionTooShort}, + {input: nullStringOfLength(1, false), want: nil}, + {input: nullStringOfLength(4097, false), want: ErrDescriptionTooLong}, + {input: nullStringOfLength(4096, false), want: nil}, + } + + for _, tc := range tests { + t.Run(tc.input.String, func(t *testing.T) { + got := validateDescription(tc.input) + validateValidator(t, tc.want, got) + }) + } +} + +func TestValidateTags(t *testing.T) { + type test struct { + input []string + existing []string + want error + } + + twentyFourTags := make([]string, 0) + for i := 0; i < 24; i++ { + twentyFourTags = append(twentyFourTags, uuid.New().String()) + } + + twentyFiveTags := make([]string, 0) + for i := 0; i < 25; i++ { + twentyFiveTags = append(twentyFiveTags, uuid.New().String()) + } + + tests := []test{ + {input: []string{"x", "x"}, existing: []string{}, want: ErrDuplicateTag}, + {input: []string{"x"}, existing: []string{"x"}, want: ErrDuplicateTag}, + {input: []string{""}, existing: []string{}, want: ErrTagTooShort}, + {input: []string{"x"}, existing: []string{}, want: nil}, + {input: []string{stringOfLength("x", 129)}, existing: []string{}, want: ErrTagTooLong}, + {input: []string{stringOfLength("x", 128)}, existing: []string{}, want: nil}, + {input: []string{"?"}, existing: []string{}, want: ErrTagInvalidChar}, + {input: twentyFiveTags, existing: []string{}, want: ErrTooManyTags}, + {input: twentyFourTags, existing: []string{}, want: nil}, + } + + for _, tc := range tests { + t.Run(fmt.Sprintf("%+v", tc.input), func(t *testing.T) { + got := validateTags(tc.input, tc.existing) + validateValidator(t, tc.want, got) + }) + } +} + +func TestValidateFirst(t *testing.T) { + type test struct { + input NullInt64 + want error + } + + tests := []test{ + {input: nullInt64(1, false), want: nil}, + {input: nullInt64(0, true), want: nil}, + {input: nullInt64(0, false), want: ErrFirstTooSmall}, + } + + for _, tc := range tests { + t.Run(fmt.Sprintf("%d", tc.input.Int64), func(t *testing.T) { + got := validateFirst(tc.input) + validateValidator(t, tc.want, got) + }) + } +} + +func TestValidateOrder(t *testing.T) { + type test struct { + input Order + want error + } + + tests := []test{ + {input: OrderName, want: nil}, + {input: OrderModified, want: nil}, + {input: "", want: ErrInvalidOrder}, + {input: "Description", want: ErrInvalidOrder}, + } + + for _, tc := range tests { + t.Run(string(tc.input), func(t *testing.T) { + got := validateOrder(tc.input) + validateValidator(t, tc.want, got) + }) + } +} + +func TestValidateDirection(t *testing.T) { + type test struct { + input Direction + want error + } + + tests := []test{ + {input: DirectionAsc, want: nil}, + {input: DirectionDesc, want: nil}, + {input: "", want: ErrInvalidDirection}, + {input: "up", want: ErrInvalidDirection}, + } + + for _, tc := range tests { + t.Run(string(tc.input), func(t *testing.T) { + got := validateDirection(tc.input) + validateValidator(t, tc.want, got) + }) + } +} + +func TestValidateQuery(t *testing.T) { + type test struct { + input NullString + want error + } + + tests := []test{ + {input: nullStringOfLength(0, true), want: nil}, + {input: nullStringOfLength(3, false), want: nil}, + {input: nullStringOfLength(2, false), want: ErrQueryTooShort}, + } + + for _, tc := range tests { + t.Run(tc.input.String, func(t *testing.T) { + got := validateQuery(tc.input) + validateValidator(t, tc.want, got) + }) + } +} + +// nullStringOfLength generates a string of a desired length +func stringOfLength(substr string, length int) string { + var str = "" + for i := 0; i < length; i++ { + str = str + substr + } + + return str +} + +// nullStringOfLength generates a NullString of a desired length +func nullStringOfLength(length int, isNull bool) NullString { + str := stringOfLength("x", length) + + return NullString{ + NullString: sql.NullString{ + Valid: !isNull, + String: str, + }, + Dirty: true, + } +} + +// nullInt64 generates a NullInt64 of a desired value. +func nullInt64(num int64, isNull bool) NullInt64 { + return NullInt64{ + NullInt64: sql.NullInt64{ + Valid: !isNull, + Int64: num, + }, + Dirty: true, + } +} + +// validateValidator validates the result of running a validator function. +func validateValidator(t *testing.T, want error, got error) { + if want == nil && got == nil { + return + } else if errors.Is(got, want) { + return + } else { + t.Errorf("got %+v; want %+v", got, want) + } +} diff --git a/nonce.txt b/nonce.txt new file mode 100644 index 0000000..f431203 --- /dev/null +++ b/nonce.txt @@ -0,0 +1 @@ +tdkkasdkas diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..9eb132a --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +(import + ( + let + lock = builtins.fromJSON (builtins.readFile ./flake.lock); + in + fetchTarball { + url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; + sha256 = lock.nodes.flake-compat.locked.narHash; + } + ) + { + src = ./.; + }).shellNix