diff --git a/Makefile b/Makefile index 16f8a8694..28f2ed753 100644 --- a/Makefile +++ b/Makefile @@ -26,6 +26,9 @@ LOCAL_BUILD_PLATFORM = linux/$(shell go env GOARCH) # Testing GO_TEST_FLAGS ?= -v -race -count=1 -tags $(BUILD_TAGS) -covermode=atomic -coverprofile=coverage.out GOTESTFMT_FLAGS ?= +SHIORI_TEST_MYSQL_URL ?=shiori:shiori@tcp(127.0.0.1:3306)/shiori +SHIORI_TEST_MARIADB_URL ?= shiori:shiori@tcp(127.0.0.1:3307)/shiori +SHIORI_TEST_PG_URL ?= postgres://shiori:shiori@127.0.0.1:5432/shiori?sslmode=disable # Development GIN_MODE ?= debug @@ -47,6 +50,10 @@ export BUILDX_PLATFORMS export SOURCE_FILES +export SHIORI_TEST_MYSQL_URL +export SHIORI_TEST_MARIADB_URL +export SHIORI_TEST_PG_URL + # Help documentatin à la https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html .PHONY: help help: diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 22563b8d7..312f833c2 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -287,6 +287,48 @@ const docTemplate = `{ } } }, + "/api/v1/bookmarks/bulk/tags": { + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Bulk update tags for multiple bookmarks.", + "parameters": [ + { + "description": "Bulk Update Bookmark Tags Payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api_v1.bulkUpdateBookmarkTagsPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.BookmarkDTO" + } + } + }, + "400": { + "description": "Invalid request payload" + }, + "403": { + "description": "Token not provided/invalid" + }, + "404": { + "description": "No bookmarks found" + } + } + } + }, "/api/v1/bookmarks/cache": { "put": { "produces": [ @@ -381,7 +423,7 @@ const docTemplate = `{ "schema": { "type": "array", "items": { - "$ref": "#/definitions/model.Tag" + "$ref": "#/definitions/model.TagDTO" } } }, @@ -392,10 +434,191 @@ const docTemplate = `{ "description": "Internal server error" } } + }, + "post": { + "description": "Create a new tag", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "Create tag", + "parameters": [ + { + "description": "Tag data", + "name": "tag", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.TagDTO" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/model.TagDTO" + } + }, + "400": { + "description": "Invalid request" + }, + "403": { + "description": "Authentication required" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/v1/tags/{id}": { + "get": { + "description": "Get a tag by ID", + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "Get tag", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.TagDTO" + } + }, + "403": { + "description": "Authentication required" + }, + "404": { + "description": "Tag not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "put": { + "description": "Update an existing tag", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "Update tag", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Tag data", + "name": "tag", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.TagDTO" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.TagDTO" + } + }, + "400": { + "description": "Invalid request" + }, + "403": { + "description": "Authentication required" + }, + "404": { + "description": "Tag not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "delete": { + "description": "Delete a tag", + "tags": [ + "Tags" + ], + "summary": "Delete tag", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "403": { + "description": "Authentication required" + }, + "404": { + "description": "Tag not found" + }, + "500": { + "description": "Internal server error" + } + } } } }, "definitions": { + "api_v1.bulkUpdateBookmarkTagsPayload": { + "type": "object", + "required": [ + "bookmark_ids", + "tag_ids" + ], + "properties": { + "bookmark_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "tag_ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "api_v1.infoResponse": { "type": "object", "properties": { @@ -602,17 +825,6 @@ const docTemplate = `{ } } }, - "model.Tag": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - } - } - }, "model.TagDTO": { "type": "object", "properties": { diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index cfaf818b5..ca0348bda 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -276,6 +276,48 @@ } } }, + "/api/v1/bookmarks/bulk/tags": { + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Bulk update tags for multiple bookmarks.", + "parameters": [ + { + "description": "Bulk Update Bookmark Tags Payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api_v1.bulkUpdateBookmarkTagsPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.BookmarkDTO" + } + } + }, + "400": { + "description": "Invalid request payload" + }, + "403": { + "description": "Token not provided/invalid" + }, + "404": { + "description": "No bookmarks found" + } + } + } + }, "/api/v1/bookmarks/cache": { "put": { "produces": [ @@ -370,7 +412,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/model.Tag" + "$ref": "#/definitions/model.TagDTO" } } }, @@ -381,10 +423,191 @@ "description": "Internal server error" } } + }, + "post": { + "description": "Create a new tag", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "Create tag", + "parameters": [ + { + "description": "Tag data", + "name": "tag", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.TagDTO" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/model.TagDTO" + } + }, + "400": { + "description": "Invalid request" + }, + "403": { + "description": "Authentication required" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/v1/tags/{id}": { + "get": { + "description": "Get a tag by ID", + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "Get tag", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.TagDTO" + } + }, + "403": { + "description": "Authentication required" + }, + "404": { + "description": "Tag not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "put": { + "description": "Update an existing tag", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "Update tag", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Tag data", + "name": "tag", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.TagDTO" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.TagDTO" + } + }, + "400": { + "description": "Invalid request" + }, + "403": { + "description": "Authentication required" + }, + "404": { + "description": "Tag not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "delete": { + "description": "Delete a tag", + "tags": [ + "Tags" + ], + "summary": "Delete tag", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "403": { + "description": "Authentication required" + }, + "404": { + "description": "Tag not found" + }, + "500": { + "description": "Internal server error" + } + } } } }, "definitions": { + "api_v1.bulkUpdateBookmarkTagsPayload": { + "type": "object", + "required": [ + "bookmark_ids", + "tag_ids" + ], + "properties": { + "bookmark_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "tag_ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "api_v1.infoResponse": { "type": "object", "properties": { @@ -591,17 +814,6 @@ } } }, - "model.Tag": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - } - } - }, "model.TagDTO": { "type": "object", "properties": { diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index c9d48d4e9..4e14790e0 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -1,4 +1,18 @@ definitions: + api_v1.bulkUpdateBookmarkTagsPayload: + properties: + bookmark_ids: + items: + type: integer + type: array + tag_ids: + items: + type: integer + type: array + required: + - bookmark_ids + - tag_ids + type: object api_v1.infoResponse: properties: database: @@ -134,13 +148,6 @@ definitions: url: type: string type: object - model.Tag: - properties: - id: - type: integer - name: - type: string - type: object model.TagDTO: properties: bookmark_count: @@ -353,6 +360,33 @@ paths: summary: Refresh a token for an account tags: - Auth + /api/v1/bookmarks/bulk/tags: + put: + parameters: + - description: Bulk Update Bookmark Tags Payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/api_v1.bulkUpdateBookmarkTagsPayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/model.BookmarkDTO' + type: array + "400": + description: Invalid request payload + "403": + description: Token not provided/invalid + "404": + description: No bookmarks found + summary: Bulk update tags for multiple bookmarks. + tags: + - Auth /api/v1/bookmarks/cache: put: parameters: @@ -414,7 +448,7 @@ paths: description: OK schema: items: - $ref: '#/definitions/model.Tag' + $ref: '#/definitions/model.TagDTO' type: array "403": description: Authentication required @@ -423,4 +457,110 @@ paths: summary: List tags tags: - Tags + post: + consumes: + - application/json + description: Create a new tag + parameters: + - description: Tag data + in: body + name: tag + required: true + schema: + $ref: '#/definitions/model.TagDTO' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/model.TagDTO' + "400": + description: Invalid request + "403": + description: Authentication required + "500": + description: Internal server error + summary: Create tag + tags: + - Tags + /api/v1/tags/{id}: + delete: + description: Delete a tag + parameters: + - description: Tag ID + in: path + name: id + required: true + type: integer + responses: + "204": + description: No Content + "403": + description: Authentication required + "404": + description: Tag not found + "500": + description: Internal server error + summary: Delete tag + tags: + - Tags + get: + description: Get a tag by ID + parameters: + - description: Tag ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.TagDTO' + "403": + description: Authentication required + "404": + description: Tag not found + "500": + description: Internal server error + summary: Get tag + tags: + - Tags + put: + consumes: + - application/json + description: Update an existing tag + parameters: + - description: Tag ID + in: path + name: id + required: true + type: integer + - description: Tag data + in: body + name: tag + required: true + schema: + $ref: '#/definitions/model.TagDTO' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.TagDTO' + "400": + description: Invalid request + "403": + description: Authentication required + "404": + description: Tag not found + "500": + description: Internal server error + summary: Update tag + tags: + - Tags swagger: "2.0" diff --git a/go.mod b/go.mod index 4f0c0ba1e..54928d84b 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/go-sql-driver/mysql v1.9.0 github.com/gofrs/uuid/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/huandu/go-sqlbuilder v1.34.0 github.com/jmoiron/sqlx v1.4.0 github.com/julienschmidt/httprouter v1.3.0 github.com/lib/pq v1.10.9 @@ -75,7 +76,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect github.com/google/uuid v1.6.0 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/huandu/xstrings v1.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.0 // indirect @@ -121,12 +122,8 @@ require ( golang.org/x/tools v0.30.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241230172942-26aa7a208def // indirect google.golang.org/grpc v1.69.2 // indirect - google.golang.org/protobuf v1.36.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/gc/v3 v3.0.0-20250225134559-fd9931328834 // indirect modernc.org/libc v1.61.13 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.8.2 // indirect - modernc.org/strutil v1.2.1 // indirect - modernc.org/token v1.1.0 // indirect ) diff --git a/go.sum b/go.sum index 53ec07b5b..a084b9f80 100644 --- a/go.sum +++ b/go.sum @@ -6,16 +6,12 @@ git.sr.ht/~emersion/go-sqlite3-fts5 v0.0.0-20240124102820-f3a72e8b79b1 h1:0j/o1v git.sr.ht/~emersion/go-sqlite3-fts5 v0.0.0-20240124102820-f3a72e8b79b1/go.mod h1:W+na+JMhhelFn525wvV3enh0zvvhtZF8kndnRanLLq0= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU= -github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY= github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8= github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= @@ -32,7 +28,6 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= @@ -45,8 +40,6 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1 github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4= -github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0= github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= @@ -59,12 +52,8 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= -github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= -github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= -github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -86,24 +75,17 @@ github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ github.com/go-shiori/dom v0.0.0-20190930082056-9d974a4f8b25/go.mod h1:360KoNl36ftFYhjLHuEty78kWUGw8i1opEicvIDLfRk= github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w= github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM= -github.com/go-shiori/go-epub v1.2.2-0.20240211121944-dc6435eac436 h1:eLPGGYvm3KFFqJ8K0gR1tGQOejAGKGeKLiCtSDPR3ss= -github.com/go-shiori/go-epub v1.2.2-0.20240211121944-dc6435eac436/go.mod h1:3rCTODnigEgy2j3ksndClrGT9h/dcz3js9q4yPX7hf8= github.com/go-shiori/go-epub v1.2.2-0.20241010194245-bd691046db94 h1:fDswMm2PrEwdnbVvz4QI/Hjm5eZ8HROzuCRYZd/Wung= github.com/go-shiori/go-epub v1.2.2-0.20241010194245-bd691046db94/go.mod h1:3q72SS/xhacgTr51ykGWJGSh3/l2lpB10CcLW+gO3Rw= -github.com/go-shiori/go-readability v0.0.0-20241012063810-92284fa8a71f h1:cypj7SJh+47G9J3VCPdMzT3uWcXWAWDJA54ErTfOigI= -github.com/go-shiori/go-readability v0.0.0-20241012063810-92284fa8a71f/go.mod h1:YWa00ashoPZMAOElrSn4E1cJErhDVU6PWAll4Hxzn+w= github.com/go-shiori/go-readability v0.0.0-20250217085726-9f5bf5ca7612 h1:BYLNYdZaepitbZreRIa9xeCQZocWmy/wj4cGIH0qyw0= github.com/go-shiori/go-readability v0.0.0-20250217085726-9f5bf5ca7612/go.mod h1:wgqthQa8SAYs0yyljVeCOQlZ027VW5CmLsbi9jWC08c= github.com/go-shiori/warc v0.0.0-20200621032813-359908319d1d h1:+SEf4hYDaAt2eyq8Xu3YyWCpnMsK8sZfbYsDRFCUgBM= github.com/go-shiori/warc v0.0.0-20200621032813-359908319d1d/go.mod h1:uaK5DAxFig7atOzy+aqLzhs6qJacMDfs8NxHV5+shzc= -github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo= github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw= github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= -github.com/gofrs/uuid/v5 v5.3.0 h1:m0mUMr+oVYUdxpMLgSYCZiXe7PuVPnI94+OMeVBNedk= -github.com/gofrs/uuid/v5 v5.3.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= github.com/gofrs/uuid/v5 v5.3.1 h1:aPx49MwJbekCzOyhZDjJVb0hx3A0KLjlbLx6p2gY0p0= github.com/gofrs/uuid/v5 v5.3.1/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -113,17 +95,21 @@ github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs= +github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs= +github.com/huandu/go-sqlbuilder v1.34.0 h1:m0l8JVVUfABCWOur3wldQ3X97WXuvvr/4UBACp7+f3s= +github.com/huandu/go-sqlbuilder v1.34.0/go.mod h1:mS0GAtrtW+XL6nM2/gXHRJax2RwSW1TraavWDFAc1JA= +github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= +github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= @@ -134,8 +120,6 @@ github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4d github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -146,19 +130,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0= -github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= github.com/lufia/plan9stats v0.0.0-20250224150550-a661cff19cfb h1:YU0XAr3+rMpM8fP80KEesn32Qa9qkbquokvuwzWyYuA= github.com/lufia/plan9stats v0.0.0-20250224150550-a661cff19cfb/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= @@ -179,8 +158,6 @@ github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= @@ -197,8 +174,6 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/playwright-community/playwright-go v0.4901.0 h1:d+1KxF5PNAHZ0gTMQ9bPSyYRWii8soJ7Rt0gLWDejc4= -github.com/playwright-community/playwright-go v0.4901.0/go.mod h1:kBNWs/w2aJ2ZUp1wEOOFLXgOqvppFngM5OS+qyhl+ZM= github.com/playwright-community/playwright-go v0.5001.0 h1:EY3oB+rU9cUp6CLHguWE8VMZTwAg+83Yyb7dQqEmGLg= github.com/playwright-community/playwright-go v0.5001.0/go.mod h1:kBNWs/w2aJ2ZUp1wEOOFLXgOqvppFngM5OS+qyhl+ZM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -214,8 +189,6 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/sethvargo/go-envconfig v1.0.2 h1:BAQnzBLK/mPN3R3pC0d46MLN0htc64YZBVrz/sZfAX4= -github.com/sethvargo/go-envconfig v1.0.2/go.mod h1:OKZ02xFaD3MvWBBmEW45fQr08sJEsonGrrOdicvQmQA= github.com/sethvargo/go-envconfig v1.1.1 h1:JDu8Q9baIzJf47NPkzhIB6aLYL0vQ+pPypoYrejS9QY= github.com/sethvargo/go-envconfig v1.1.1/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= @@ -231,16 +204,10 @@ github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92/go.mod h1:7/OT02F6 github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -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/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -252,8 +219,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= -github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU= github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0= github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg= @@ -264,8 +229,6 @@ github.com/tdewolff/parse v2.3.4+incompatible h1:x05/cnGwIMf4ceLuDMBOdQ1qGniMoxp github.com/tdewolff/parse v2.3.4+incompatible/go.mod h1:8oBwCsVmUkgHO8M5iCzSIDtpzXOT0WXX9cWhz+bIzJQ= github.com/tdewolff/test v1.0.0 h1:jOwzqCXr5ePXEPGJaq2ivoR6HOCi+D5TPfpoyg8yvmU= github.com/tdewolff/test v1.0.0/go.mod h1:DiQUlutnqlEvdvhSn2LPGy4TFwRauAaYDsL+683RNX4= -github.com/testcontainers/testcontainers-go v0.34.0 h1:5fbgF0vIN5u+nD3IWabQwRybuB4GY8G2HHgCkbMzMHo= -github.com/testcontainers/testcontainers-go v0.34.0/go.mod h1:6P/kMkQe8yqPHfPWNulFGdFHTD8HB2vLq/231xY2iPQ= github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo= github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4= github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= @@ -280,32 +243,22 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= -go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= -go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= -go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= -go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= -go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= -go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= -go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= -go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= @@ -317,17 +270,12 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= -golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo= -golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 h1:aWwlzYV971S4BXRS9AmqwDLAD85ouC6X+pocatKY58c= golang.org/x/exp v0.0.0-20250228200357-dead58393ab7/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= -golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -337,8 +285,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= -golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= +golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190926025831-c00fd9afed17/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -351,7 +299,6 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= @@ -363,8 +310,9 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -378,7 +326,6 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -386,7 +333,6 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -398,7 +344,6 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= @@ -410,12 +355,11 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -424,17 +368,15 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= -golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= -google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53 h1:fVoAXEKA4+yufmbdVYv+SE73+cPZbbbe8paLsHfkK+U= -google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53/go.mod h1:riSXTwQ4+nqmPGtobMFyW5FqVAmIs0St6VPp4Ug7CE4= +google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= google.golang.org/genproto/googleapis/rpc v0.0.0-20241230172942-26aa7a208def h1:4P81qv5JXI/sDNae2ClVx88cgDDA6DPilADkG9tYKz8= google.golang.org/genproto/googleapis/rpc v0.0.0-20241230172942-26aa7a208def/go.mod h1:bdAgzvd4kFrpykc5/AC2eLUiegK9T/qxZHD4hXYf/ho= google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= @@ -449,34 +391,24 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= -modernc.org/cc/v4 v4.24.2 h1:uektamHbSXU7egelXcyVpMaaAsrRH4/+uMKUQAQUdOw= -modernc.org/cc/v4 v4.24.2/go.mod h1:T1lKJZhXIi2VSqGBiB4LIbKs9NsKTbUXj4IDrmGqtTI= -modernc.org/ccgo/v4 v4.23.5 h1:6uAwu8u3pnla3l/+UVUrDDO1HIGxHTYmFH6w+X9nsyw= -modernc.org/ccgo/v4 v4.23.5/go.mod h1:FogrWfBdzqLWm1ku6cfr4IzEFouq2fSAPf6aSAHdAJQ= +modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= +modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo= +modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo= modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= -modernc.org/gc/v2 v2.6.0 h1:Tiw3pezQj7PfV8k4Dzyu/vhRHR2e92kOXtTFU8pbCl4= -modernc.org/gc/v2 v2.6.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= -modernc.org/gc/v3 v3.0.0-20241223112719-96e2e1e4408d h1:d0JExN5U5FjUVHCP6L9DIlLJBZveR6KUM4AvfDUL3+k= -modernc.org/gc/v3 v3.0.0-20241223112719-96e2e1e4408d/go.mod h1:qBSLm/exCqouT2hrfyTKikWKG9IPq8EoX5fS00l3jqk= -modernc.org/gc/v3 v3.0.0-20250225134559-fd9931328834 h1:Qv+IG+6zQZSvUPRwUOgnMoAhy6rAhBB7WYY8IAfKG+c= -modernc.org/gc/v3 v3.0.0-20250225134559-fd9931328834/go.mod h1:LG5UO1Ran4OO0JRKz2oNiXhR5nNrgz0PzH7UKhz0aMU= -modernc.org/libc v1.61.6 h1:L2jW0wxHPCyHK0YSHaGaVlY0WxjpG/TTVdg6gRJOPqw= -modernc.org/libc v1.61.6/go.mod h1:G+DzuaCcReUYYg4nNSfigIfTDCENdj9EByglvaRx53A= +modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw= +modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8= modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= -modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI= modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU= -modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= -modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= -modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= -modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8= -modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.36.0 h1:EQXNRn4nIS+gfsKeUTymHIz1waxuv5BzU7558dHSfH8= modernc.org/sqlite v1.36.0/go.mod h1:7MPwH7Z6bREicF9ZVUR78P1IKuxfZ8mRIDHD0iD+8TU= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 48ccf8a95..425765031 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -29,11 +29,22 @@ func testDatabase(t *testing.T, dbFactory testDatabaseFactory) { "testGetBookmark": testGetBookmark, "testGetBookmarkNotExistent": testGetBookmarkNotExistent, "testGetBookmarks": testGetBookmarks, + "testGetBookmarksWithTags": testGetBookmarksWithTags, "testGetBookmarksWithSQLCharacters": testGetBookmarksWithSQLCharacters, "testGetBookmarksCount": testGetBookmarksCount, + "testSaveBookmark": testSaveBookmark, + "testBulkUpdateBookmarkTags": testBulkUpdateBookmarkTags, // Tags - "testCreateTag": testCreateTag, - "testCreateTags": testCreateTags, + "testCreateTag": testCreateTag, + "testCreateTags": testCreateTags, + "testGetTags": testGetTags, + "testGetTagsBookmarkCount": testGetTagsBookmarkCount, + "testGetTag": testGetTag, + "testGetTagNotExistent": testGetTagNotExistent, + "testUpdateTag": testUpdateTag, + "testRenameTag": testRenameTag, + "testDeleteTag": testDeleteTag, + "testDeleteTagNotExistent": testDeleteTagNotExistent, // Accounts "testCreateAccount": testCreateAccount, "testCreateDuplicateAccount": testCreateDuplicateAccount, @@ -241,8 +252,8 @@ func testGetBookmark(t *testing.T, db model.DB) { assert.NoError(t, err, "Save bookmarks must not fail") savedBookmark, exists, err := db.GetBookmark(ctx, result[0].ID, "") - assert.True(t, exists, "Bookmark should exist") assert.NoError(t, err, "Get bookmark should not fail") + assert.True(t, exists, "Bookmark should exist") assert.Equal(t, result[0].ID, savedBookmark.ID, "Retrieved bookmark should be the same") assert.Equal(t, book.URL, savedBookmark.URL, "Retrieved bookmark should be the same") } @@ -308,6 +319,155 @@ func testGetBookmarksWithSQLCharacters(t *testing.T, db model.DB) { } } +func testGetBookmarksWithTags(t *testing.T, db model.DB) { + ctx := context.TODO() + + // Create test tags + tags := []model.Tag{ + {Name: "programming"}, + {Name: "golang"}, + {Name: "database"}, + {Name: "testing"}, + } + createdTags, err := db.CreateTags(ctx, tags...) + require.NoError(t, err) + require.Len(t, createdTags, 4) + + // Create bookmarks with different tag combinations + bookmarks := []model.BookmarkDTO{ + { + URL: "https://golang.org", + Title: "Go Language", + Tags: []model.TagDTO{ + {Tag: model.Tag{Name: "programming"}}, + {Tag: model.Tag{Name: "golang"}}, + }, + }, + { + URL: "https://postgresql.org", + Title: "PostgreSQL", + Tags: []model.TagDTO{ + {Tag: model.Tag{Name: "programming"}}, + {Tag: model.Tag{Name: "database"}}, + }, + }, + { + URL: "https://sqlite.org", + Title: "SQLite", + Tags: []model.TagDTO{ + {Tag: model.Tag{Name: "database"}}, + }, + }, + { + URL: "https://example.com", + Title: "No Tags Example", + }, + } + + // Save all bookmarks + for _, bookmark := range bookmarks { + results, err := db.SaveBookmarks(ctx, true, bookmark) + require.NoError(t, err) + require.Len(t, results, 1) + } + + tests := []struct { + name string + opts model.DBGetBookmarksOptions + expectedCount int + expectedTitles []string + }{ + { + name: "single tag - programming", + opts: model.DBGetBookmarksOptions{ + Tags: []string{"programming"}, + }, + expectedCount: 2, + expectedTitles: []string{"Go Language", "PostgreSQL"}, + }, + { + name: "multiple tags - programming AND golang", + opts: model.DBGetBookmarksOptions{ + Tags: []string{"programming", "golang"}, + }, + expectedCount: 1, + expectedTitles: []string{"Go Language"}, + }, + { + name: "all tags using *", + opts: model.DBGetBookmarksOptions{ + Tags: []string{"*"}, + }, + expectedCount: 3, + expectedTitles: []string{"Go Language", "PostgreSQL", "SQLite"}, + }, + { + name: "exclude database tag", + opts: model.DBGetBookmarksOptions{ + ExcludedTags: []string{"database"}, + }, + expectedCount: 2, + expectedTitles: []string{"Go Language", "No Tags Example"}, + }, + { + name: "no tags only", + opts: model.DBGetBookmarksOptions{ + ExcludedTags: []string{"*"}, + }, + expectedCount: 1, + expectedTitles: []string{"No Tags Example"}, + }, + { + name: "non-existent tag", + opts: model.DBGetBookmarksOptions{ + Tags: []string{"nonexistent"}, + }, + expectedCount: 0, + expectedTitles: []string{}, + }, + } + + t.Run("ensure tags are present", func(t *testing.T) { + tags, err := db.GetTags(ctx) + require.NoError(t, err) + assert.Len(t, tags, 4) + }) + + t.Run("ensure test data is correct", func(t *testing.T) { + results, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{}) + require.NoError(t, err) + require.Len(t, results, 4) + for _, book := range results { + if book.Title == "No Tags Example" { + assert.Empty(t, book.Tags) + } else { + assert.NotEmpty(t, book.Tags) + } + + // Ensure tags contain their ID and name + for _, tag := range book.Tags { + assert.NotZero(t, tag.ID) + assert.NotEmpty(t, tag.Name) + } + } + }) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results, err := db.GetBookmarks(ctx, tt.opts) + require.NoError(t, err) + assert.Len(t, results, tt.expectedCount) + + // Check if all expected titles are present + titles := make([]string, len(results)) + for i, result := range results { + titles[i] = result.Title + } + assert.ElementsMatch(t, tt.expectedTitles, titles) + }) + } +} + func testGetBookmarksCount(t *testing.T, db model.DB) { ctx := context.TODO() @@ -330,14 +490,22 @@ func testGetBookmarksCount(t *testing.T, db model.DB) { func testCreateTag(t *testing.T, db model.DB) { ctx := context.TODO() tag := model.Tag{Name: "shiori"} - err := db.CreateTags(ctx, tag) + createdTags, err := db.CreateTags(ctx, tag) assert.NoError(t, err, "Save tag must not fail") + assert.Len(t, createdTags, 1, "Should return one created tag") + assert.Greater(t, createdTags[0].ID, 0, "Created tag should have a valid ID") + assert.Equal(t, "shiori", createdTags[0].Name, "Created tag should have the correct name") } func testCreateTags(t *testing.T, db model.DB) { ctx := context.TODO() - err := db.CreateTags(ctx, model.Tag{Name: "shiori"}, model.Tag{Name: "shiori2"}) + createdTags, err := db.CreateTags(ctx, model.Tag{Name: "shiori"}, model.Tag{Name: "shiori2"}) assert.NoError(t, err, "Save tag must not fail") + assert.Len(t, createdTags, 2, "Should return two created tags") + assert.Greater(t, createdTags[0].ID, 0, "First created tag should have a valid ID") + assert.Greater(t, createdTags[1].ID, 0, "Second created tag should have a valid ID") + assert.Equal(t, "shiori", createdTags[0].Name, "First created tag should have the correct name") + assert.Equal(t, "shiori2", createdTags[1].Name, "Second created tag should have the correct name") } // ----------------- ACCOUNTS ----------------- @@ -449,7 +617,7 @@ func testGetAccount(t *testing.T, db model.DB) { // Failed case account, exists, err := db.GetAccount(ctx, 99) - assert.NotNil(t, err) + assert.ErrorIs(t, err, ErrNotFound) assert.False(t, exists, "Expected account to exist") assert.Empty(t, account.Username) } @@ -641,3 +809,531 @@ func testGetBoomarksWithTimeFilters(t *testing.T, db model.DB) { // Second id should be 2 if order them by id assert.Equal(t, booksOrderById[1].ID, 2) } + +// Additional tag test functions + +func testGetTags(t *testing.T, db model.DB) { + ctx := context.TODO() + + // Create initial tag to ensure there's at least one tag + initialTag := model.Tag{Name: "initial-test-tag"} + _, err := db.CreateTags(ctx, initialTag) + require.NoError(t, err) + + // Create additional tags + tags := []model.Tag{ + {Name: "tag1"}, + {Name: "tag2"}, + {Name: "tag3"}, + } + createdTags, err := db.CreateTags(ctx, tags...) + require.NoError(t, err) + require.Len(t, createdTags, 3) + + // Fetch all tags + fetchedTags, err := db.GetTags(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(fetchedTags), 4) // At least 3 new tags + 1 initial tag + + // Check that all expected tags are present + tagNames := make(map[string]bool) + for _, tag := range fetchedTags { + tagNames[tag.Name] = true + } + + assert.True(t, tagNames["tag1"], "Tag 'tag1' should be present") + assert.True(t, tagNames["tag2"], "Tag 'tag2' should be present") + assert.True(t, tagNames["tag3"], "Tag 'tag3' should be present") + assert.True(t, tagNames["initial-test-tag"], "Tag 'initial-test-tag' should be present") +} + +func testGetTag(t *testing.T, db model.DB) { + ctx := context.TODO() + + // Create a tag + tag := model.Tag{Name: "get-tag-test"} + createdTags, err := db.CreateTags(ctx, tag) + require.NoError(t, err) + require.Len(t, createdTags, 1) + tagID := createdTags[0].ID + + // Get the tag + fetchedTag, exists, err := db.GetTag(ctx, tagID) + require.NoError(t, err) + require.True(t, exists) + assert.Equal(t, tagID, fetchedTag.ID) + assert.Equal(t, tag.Name, fetchedTag.Name) +} + +func testGetTagNotExistent(t *testing.T, db model.DB) { + ctx := context.TODO() + + // Test non-existent tag + nonExistentTag, exists, err := db.GetTag(ctx, 9999) + require.NoError(t, err) + require.False(t, exists) + assert.Empty(t, nonExistentTag.Name) +} + +func testUpdateTag(t *testing.T, db model.DB) { + ctx := context.TODO() + + // Create a tag + tag := model.Tag{Name: "update-tag-test"} + createdTags, err := db.CreateTags(ctx, tag) + require.NoError(t, err) + require.Len(t, createdTags, 1) + + // Update the tag + tagToUpdate := model.Tag{ + ID: createdTags[0].ID, + Name: "updated-tag", + } + err = db.UpdateTag(ctx, tagToUpdate) + require.NoError(t, err) + + // Verify the tag was updated + updatedTag, exists, err := db.GetTag(ctx, tagToUpdate.ID) + require.NoError(t, err) + require.True(t, exists) + assert.Equal(t, "updated-tag", updatedTag.Name) +} + +func testRenameTag(t *testing.T, db model.DB) { + ctx := context.TODO() + + // Create a tag + tag := model.Tag{Name: "rename-tag-test"} + createdTags, err := db.CreateTags(ctx, tag) + require.NoError(t, err) + require.Len(t, createdTags, 1) + tagID := createdTags[0].ID + + // Rename the tag + err = db.RenameTag(ctx, tagID, "renamed-tag") + require.NoError(t, err) + + // Verify the tag was renamed + renamedTag, exists, err := db.GetTag(ctx, tagID) + require.NoError(t, err) + require.True(t, exists) + assert.Equal(t, "renamed-tag", renamedTag.Name) +} + +func testDeleteTag(t *testing.T, db model.DB) { + ctx := context.TODO() + + // Create a tag + tag := model.Tag{Name: "delete-tag-test"} + createdTags, err := db.CreateTags(ctx, tag) + require.NoError(t, err) + require.Len(t, createdTags, 1) + tagID := createdTags[0].ID + + // Delete the tag + err = db.DeleteTag(ctx, tagID) + require.NoError(t, err) + + // Verify the tag was deleted + _, exists, err := db.GetTag(ctx, tagID) + require.NoError(t, err) + require.False(t, exists) +} + +func testDeleteTagNotExistent(t *testing.T, db model.DB) { + ctx := context.TODO() + + // Test deleting a non-existent tag + err := db.DeleteTag(ctx, 9999) + require.Error(t, err) + assert.ErrorIs(t, err, ErrNotFound, "Error should be ErrNotFound") +} + +func testGetTagsBookmarkCount(t *testing.T, db model.DB) { + ctx := context.TODO() + + // Create test tags + tags := []model.Tag{ + {Name: "tag1-count"}, + {Name: "tag2-count"}, + } + + _, err := db.CreateTags(ctx, model.Tag{Name: "tag3-count"}) + require.NoError(t, err) + + // Create bookmarks with different tag combinations + bookmark1 := model.BookmarkDTO{ + URL: "https://example1.com", + Title: "Example 1", + Tags: []model.TagDTO{ + {Tag: model.Tag{Name: tags[0].Name}}, // tag1 + {Tag: model.Tag{Name: tags[1].Name}}, // tag2 + }, + } + + bookmark2 := model.BookmarkDTO{ + URL: "https://example2.com", + Title: "Example 2", + Tags: []model.TagDTO{ + {Tag: model.Tag{Name: tags[0].Name}}, // tag1 + }, + } + + bookmark3 := model.BookmarkDTO{ + URL: "https://example3.com", + Title: "Example 3", + Tags: []model.TagDTO{ + {Tag: model.Tag{Name: tags[1].Name}}, // tag2 + }, + } + + // Save bookmarks + bookmarks, err := db.SaveBookmarks(ctx, true, bookmark1, bookmark2, bookmark3) + require.NoError(t, err) + + t.Run("GetBookmarks", func(t *testing.T) { + result, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{ + Tags: []string{tags[0].Name}, + }) + require.NoError(t, err) + require.NotEmpty(t, result) + }) + + t.Run("GetTag", func(t *testing.T) { + t.Log(bookmarks[0]) + tag, exists, err := db.GetTag(ctx, bookmarks[0].Tags[0].ID) + require.NoError(t, err) + require.True(t, exists) + assert.Equal(t, tags[0].Name, tag.Name) + assert.Equal(t, int64(2), tag.BookmarkCount) + }) + + // Test GetTags + t.Run("GetTags", func(t *testing.T) { + fetchedTags, err := db.GetTags(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(fetchedTags), 3) + + // Create a map of tag name to bookmark count + tagCounts := make(map[string]int64) + for _, tag := range fetchedTags { + tagCounts[tag.Name] = tag.BookmarkCount + } + + // Verify counts + assert.Equal(t, int64(2), tagCounts["tag1-count"]) + assert.Equal(t, int64(2), tagCounts["tag2-count"]) + assert.Equal(t, int64(0), tagCounts["tag3-count"]) + }) + + // Test count updates after bookmark deletion + t.Run("CountAfterDeletion", func(t *testing.T) { + // Get the first bookmark that has tag1 + bookmarks, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{ + Tags: []string{tags[0].Name}, + }) + require.NoError(t, err) + require.NotEmpty(t, bookmarks) + require.NotEmpty(t, bookmarks[0].Tags) + + tagID := bookmarks[0].Tags[0].ID + + // Delete the first bookmark + err = db.DeleteBookmarks(ctx, bookmarks[0].ID) + require.NoError(t, err) + + // Verify updated counts + tag1, exists, err := db.GetTag(ctx, tagID) + require.NoError(t, err) + require.True(t, exists) + assert.Equal(t, int64(1), tag1.BookmarkCount, "tag1-count should have 1 bookmark after deletion") + }) +} + +func testSaveBookmark(t *testing.T, db model.DB) { + ctx := context.TODO() + + t.Run("invalid_bookmark_id", func(t *testing.T) { + bookmark := model.Bookmark{ + ID: 0, // Invalid ID + URL: "https://example.com", + Title: "Example", + } + err := db.SaveBookmark(ctx, bookmark) + require.Error(t, err) + assert.Contains(t, err.Error(), "bookmark ID must be greater than 0") + }) + + t.Run("empty_url", func(t *testing.T) { + bookmark := model.Bookmark{ + ID: 1, + URL: "", // Empty URL + Title: "Example", + } + err := db.SaveBookmark(ctx, bookmark) + require.Error(t, err) + assert.Contains(t, err.Error(), "URL must not be empty") + }) + + t.Run("empty_title", func(t *testing.T) { + bookmark := model.Bookmark{ + ID: 1, + URL: "https://example.com", + Title: "", // Empty title + } + err := db.SaveBookmark(ctx, bookmark) + require.Error(t, err) + assert.Contains(t, err.Error(), "title must not be empty") + }) + + t.Run("successful_update", func(t *testing.T) { + // First create a bookmark + bookmark := model.BookmarkDTO{ + URL: "https://example.com", + Title: "Example", + } + results, err := db.SaveBookmarks(ctx, true, bookmark) + require.NoError(t, err) + bookmarkID := results[0].ID + + // Now update it + updatedBookmark := model.Bookmark{ + ID: bookmarkID, + URL: "https://updated-example.com", + Title: "Updated Example", + Excerpt: "Updated excerpt", + Author: "Updated Author", + Public: 1, // Use 1 for SQLite, should work for other DBs too + } + + err = db.SaveBookmark(ctx, updatedBookmark) + require.NoError(t, err) + + // Verify the bookmark was updated + retrievedBookmark, exists, err := db.GetBookmark(ctx, bookmarkID, "") + require.NoError(t, err) + require.True(t, exists) + assert.Equal(t, updatedBookmark.URL, retrievedBookmark.URL) + assert.Equal(t, updatedBookmark.Title, retrievedBookmark.Title) + assert.Equal(t, updatedBookmark.Excerpt, retrievedBookmark.Excerpt) + assert.Equal(t, updatedBookmark.Author, retrievedBookmark.Author) + assert.Equal(t, updatedBookmark.Public, retrievedBookmark.Public) + }) +} + +func testBulkUpdateBookmarkTags(t *testing.T, db model.DB) { + ctx := context.TODO() + + // Create test bookmarks + bookmark1 := model.BookmarkDTO{ + URL: "https://example1.com", + Title: "Example 1", + } + bookmark2 := model.BookmarkDTO{ + URL: "https://example2.com", + Title: "Example 2", + } + bookmark3 := model.BookmarkDTO{ + URL: "https://example3.com", + Title: "Example 3", + } + + results1, err := db.SaveBookmarks(ctx, true, bookmark1) + require.NoError(t, err) + bookmark1ID := results1[0].ID + + results2, err := db.SaveBookmarks(ctx, true, bookmark2) + require.NoError(t, err) + bookmark2ID := results2[0].ID + + results3, err := db.SaveBookmarks(ctx, true, bookmark3) + require.NoError(t, err) + bookmark3ID := results3[0].ID + + // Create test tags + tag1 := model.Tag{Name: "tag1-bulk-test"} + tag2 := model.Tag{Name: "tag2-bulk-test"} + tag3 := model.Tag{Name: "tag3-bulk-test"} + tag4 := model.Tag{Name: "tag4-bulk-test"} + + createdTags, err := db.CreateTags(ctx, tag1, tag2, tag3, tag4) + require.NoError(t, err) + require.Len(t, createdTags, 4) + + tag1ID := createdTags[0].ID + tag2ID := createdTags[1].ID + tag3ID := createdTags[2].ID + tag4ID := createdTags[3].ID + + t.Run("empty_bookmark_ids", func(t *testing.T) { + err := db.BulkUpdateBookmarkTags(ctx, []int{}, []int{tag1ID, tag2ID}) + require.NoError(t, err, "Empty bookmark IDs should not cause an error") + }) + + t.Run("empty_tag_ids", func(t *testing.T) { + err := db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID, bookmark2ID}, []int{}) + require.NoError(t, err, "Empty tag IDs should not cause an error") + + // Verify tags were removed + bookmark, exists, err := db.GetBookmark(ctx, bookmark1ID, "") + require.NoError(t, err) + require.True(t, exists) + assert.Empty(t, bookmark.Tags, "Tags should be empty after update with empty tag IDs") + }) + + t.Run("non_existent_bookmark", func(t *testing.T) { + nonExistentID := 9999 + err := db.BulkUpdateBookmarkTags(ctx, []int{nonExistentID}, []int{tag1ID}) + require.Error(t, err, "Non-existent bookmark ID should cause an error") + assert.Contains(t, err.Error(), "some bookmarks do not exist") + }) + + t.Run("non_existent_tag", func(t *testing.T) { + nonExistentID := 9999 + err := db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID}, []int{nonExistentID}) + require.Error(t, err, "Non-existent tag ID should cause an error") + assert.Contains(t, err.Error(), "some tags do not exist") + }) + + t.Run("multiple_non_existent_bookmarks", func(t *testing.T) { + err := db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID, 9998, 9999}, []int{tag1ID}) + require.Error(t, err, "Multiple non-existent bookmark IDs should cause an error") + assert.Contains(t, err.Error(), "some bookmarks do not exist") + }) + + t.Run("multiple_non_existent_tags", func(t *testing.T) { + err := db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID}, []int{tag1ID, 9998, 9999}) + require.Error(t, err, "Multiple non-existent tag IDs should cause an error") + assert.Contains(t, err.Error(), "some tags do not exist") + }) + + t.Run("successful_update", func(t *testing.T) { + // Update both bookmarks with both tags + err := db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID, bookmark2ID}, []int{tag1ID, tag2ID}) + require.NoError(t, err, "Bulk update should succeed") + + // Verify bookmark1 has both tags + bookmark1, exists, err := db.GetBookmark(ctx, bookmark1ID, "") + require.NoError(t, err) + require.True(t, exists) + assert.Len(t, bookmark1.Tags, 2, "Bookmark 1 should have 2 tags") + + // Verify bookmark2 has both tags + bookmark2, exists, err := db.GetBookmark(ctx, bookmark2ID, "") + require.NoError(t, err) + require.True(t, exists) + assert.Len(t, bookmark2.Tags, 2, "Bookmark 2 should have 2 tags") + + // Verify tag names + tagNames := make(map[string]bool) + for _, tag := range bookmark1.Tags { + tagNames[tag.Name] = true + } + assert.True(t, tagNames[tag1.Name], "Bookmark 1 should have tag1") + assert.True(t, tagNames[tag2.Name], "Bookmark 1 should have tag2") + + // Update with a single tag + err = db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID}, []int{tag1ID}) + require.NoError(t, err, "Update with single tag should succeed") + + // Verify bookmark1 now has only one tag + bookmark1, exists, err = db.GetBookmark(ctx, bookmark1ID, "") + require.NoError(t, err) + require.True(t, exists) + assert.Len(t, bookmark1.Tags, 1, "Bookmark 1 should have 1 tag after update") + assert.Equal(t, tag1.Name, bookmark1.Tags[0].Name, "Bookmark 1 should have tag1") + + // Verify bookmark2 still has both tags + bookmark2, exists, err = db.GetBookmark(ctx, bookmark2ID, "") + require.NoError(t, err) + require.True(t, exists) + assert.Len(t, bookmark2.Tags, 2, "Bookmark 2 should still have 2 tags") + }) + + t.Run("multiple_updates", func(t *testing.T) { + // First update + err := db.BulkUpdateBookmarkTags(ctx, []int{bookmark3ID}, []int{tag1ID, tag2ID}) + require.NoError(t, err, "First update should succeed") + + // Verify bookmark3 has both tags + bookmark3, exists, err := db.GetBookmark(ctx, bookmark3ID, "") + require.NoError(t, err) + require.True(t, exists) + assert.Len(t, bookmark3.Tags, 2, "Bookmark 3 should have 2 tags after first update") + + // Second update with different tags + err = db.BulkUpdateBookmarkTags(ctx, []int{bookmark3ID}, []int{tag3ID, tag4ID}) + require.NoError(t, err, "Second update should succeed") + + // Verify bookmark3 now has the new tags and not the old ones + bookmark3, exists, err = db.GetBookmark(ctx, bookmark3ID, "") + require.NoError(t, err) + require.True(t, exists) + assert.Len(t, bookmark3.Tags, 2, "Bookmark 3 should have 2 tags after second update") + + // Check tag names + tagNames := make(map[string]bool) + for _, tag := range bookmark3.Tags { + tagNames[tag.Name] = true + } + assert.False(t, tagNames[tag1.Name], "Bookmark 3 should not have tag1 after second update") + assert.False(t, tagNames[tag2.Name], "Bookmark 3 should not have tag2 after second update") + assert.True(t, tagNames[tag3.Name], "Bookmark 3 should have tag3 after second update") + assert.True(t, tagNames[tag4.Name], "Bookmark 3 should have tag4 after second update") + }) + + t.Run("update_multiple_bookmarks_with_different_initial_tags", func(t *testing.T) { + // Setup: bookmark1 has tag1, bookmark2 has tag1 and tag2 + err := db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID}, []int{tag1ID}) + require.NoError(t, err) + + err = db.BulkUpdateBookmarkTags(ctx, []int{bookmark2ID}, []int{tag1ID, tag2ID}) + require.NoError(t, err) + + // Verify initial state + bookmark1, exists, err := db.GetBookmark(ctx, bookmark1ID, "") + require.NoError(t, err) + require.True(t, exists) + assert.Len(t, bookmark1.Tags, 1, "Bookmark 1 should have 1 tag initially") + + bookmark2, exists, err := db.GetBookmark(ctx, bookmark2ID, "") + require.NoError(t, err) + require.True(t, exists) + assert.Len(t, bookmark2.Tags, 2, "Bookmark 2 should have 2 tags initially") + + // Update both bookmarks with tag3 and tag4 + err = db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID, bookmark2ID}, []int{tag3ID, tag4ID}) + require.NoError(t, err, "Bulk update should succeed") + + // Verify both bookmarks now have tag3 and tag4 only + bookmark1, exists, err = db.GetBookmark(ctx, bookmark1ID, "") + require.NoError(t, err) + require.True(t, exists) + assert.Len(t, bookmark1.Tags, 2, "Bookmark 1 should have 2 tags after update") + + bookmark2, exists, err = db.GetBookmark(ctx, bookmark2ID, "") + require.NoError(t, err) + require.True(t, exists) + assert.Len(t, bookmark2.Tags, 2, "Bookmark 2 should have 2 tags after update") + + // Check tag names for bookmark1 + tagNames1 := make(map[string]bool) + for _, tag := range bookmark1.Tags { + tagNames1[tag.Name] = true + } + assert.False(t, tagNames1[tag1.Name], "Bookmark 1 should not have tag1 after update") + assert.False(t, tagNames1[tag2.Name], "Bookmark 1 should not have tag2 after update") + assert.True(t, tagNames1[tag3.Name], "Bookmark 1 should have tag3 after update") + assert.True(t, tagNames1[tag4.Name], "Bookmark 1 should have tag4 after update") + + // Check tag names for bookmark2 + tagNames2 := make(map[string]bool) + for _, tag := range bookmark2.Tags { + tagNames2[tag.Name] = true + } + assert.False(t, tagNames2[tag1.Name], "Bookmark 2 should not have tag1 after update") + assert.False(t, tagNames2[tag2.Name], "Bookmark 2 should not have tag2 after update") + assert.True(t, tagNames2[tag3.Name], "Bookmark 2 should have tag3 after update") + assert.True(t, tagNames2[tag4.Name], "Bookmark 2 should have tag4 after update") + }) +} diff --git a/internal/database/mysql.go b/internal/database/mysql.go index be9c938cf..2b7fa8203 100644 --- a/internal/database/mysql.go +++ b/internal/database/mysql.go @@ -4,10 +4,12 @@ import ( "context" "database/sql" "fmt" + "slices" "strings" "time" "github.com/go-shiori/shiori/internal/model" + "github.com/huandu/go-sqlbuilder" "github.com/jmoiron/sqlx" "github.com/pkg/errors" @@ -270,11 +272,13 @@ func (db *MySQLDatabase) SaveBookmarks(ctx context.Context, create bool, bookmar } tag.ID = int(tagID64) + t.ID = int(tagID64) } + } - if _, err := stmtInsertBookTag.ExecContext(ctx, t.ID, book.ID); err != nil { - return errors.WithStack(err) - } + // Always insert the tag-bookmark association + if _, err := stmtInsertBookTag.ExecContext(ctx, tag.ID, book.ID); err != nil { + return errors.WithStack(err) } newTags = append(newTags, t) @@ -304,7 +308,7 @@ func (db *MySQLDatabase) GetBookmarks(ctx context.Context, opts model.DBGetBookm `public`, `created_at`, `modified_at`, - `content <> "" has_content`} + `content <> "" as has_content`} if opts.WithContent { columns = append(columns, `content`, `html`) @@ -336,21 +340,15 @@ func (db *MySQLDatabase) GetBookmarks(ctx context.Context, opts model.DBGetBookm // First we check for * in excluded and included tags, // which means all tags will be excluded and included, respectively. excludeAllTags := false - for _, excludedTag := range opts.ExcludedTags { - if excludedTag == "*" { - excludeAllTags = true - opts.ExcludedTags = []string{} - break - } + if slices.Contains(opts.ExcludedTags, "*") { + excludeAllTags = true + opts.ExcludedTags = []string{} } includeAllTags := false - for _, includedTag := range opts.Tags { - if includedTag == "*" { - includeAllTags = true - opts.Tags = []string{} - break - } + if slices.Contains(opts.Tags, "*") { + includeAllTags = true + opts.Tags = []string{} } // If all tags excluded, we will only show bookmark without tags. @@ -412,28 +410,38 @@ func (db *MySQLDatabase) GetBookmarks(ctx context.Context, opts model.DBGetBookm return nil, errors.WithStack(err) } - // Fetch tags for each bookmarks - stmtGetTags, err := db.PreparexContext(ctx, `SELECT t.id, t.name - FROM bookmark_tag bt - LEFT JOIN tag t ON bt.tag_id = t.id - WHERE bt.bookmark_id = ? - ORDER BY t.name`) - if err != nil { - return nil, errors.WithStack(err) - } - defer stmtGetTags.Close() - - for _, book := range bookmarks { - book.Tags = []model.TagDTO{} - err = stmtGetTags.SelectContext(ctx, &book.Tags, book.ID) - if err != nil && err != sql.ErrNoRows { - return nil, errors.WithStack(err) + // Fetch tags for each bookmark + for i, book := range bookmarks { + tags, err := db.getTagsForBookmark(ctx, book.ID) + if err != nil { + return nil, fmt.Errorf("failed to get tags: %w", err) } + bookmarks[i].Tags = tags } return bookmarks, nil } +func (db *MySQLDatabase) getTagsForBookmark(ctx context.Context, bookmarkID int) ([]model.TagDTO, error) { + sb := sqlbuilder.MySQL.NewSelectBuilder() + sb.Select("t.id", "t.name") + sb.From("bookmark_tag bt") + sb.JoinWithOption(sqlbuilder.LeftJoin, "tag t", "bt.tag_id = t.id") + sb.Where(sb.Equal("bt.bookmark_id", bookmarkID)) + sb.OrderBy("t.name") + + query, args := sb.Build() + query = db.ReaderDB().Rebind(query) + + tags := []model.TagDTO{} + err := db.ReaderDB().SelectContext(ctx, &tags, query, args...) + if err != nil && err != sql.ErrNoRows { + return nil, fmt.Errorf("failed to get tags: %w", err) + } + + return tags, nil +} + // GetBookmarksCount fetch count of bookmarks based on submitted options. func (db *MySQLDatabase) GetBookmarksCount(ctx context.Context, opts model.DBGetBookmarksOptions) (int, error) { // Create initial query @@ -464,21 +472,15 @@ func (db *MySQLDatabase) GetBookmarksCount(ctx context.Context, opts model.DBGet // First we check for * in excluded and included tags, // which means all tags will be excluded and included, respectively. excludeAllTags := false - for _, excludedTag := range opts.ExcludedTags { - if excludedTag == "*" { - excludeAllTags = true - opts.ExcludedTags = []string{} - break - } + if slices.Contains(opts.ExcludedTags, "*") { + excludeAllTags = true + opts.ExcludedTags = []string{} } includeAllTags := false - for _, includedTag := range opts.Tags { - if includedTag == "*" { - includeAllTags = true - opts.Tags = []string{} - break - } + if slices.Contains(opts.Tags, "*") { + includeAllTags = true + opts.Tags = []string{} } // If all tags excluded, we will only show bookmark without tags. @@ -577,23 +579,57 @@ func (db *MySQLDatabase) DeleteBookmarks(ctx context.Context, ids ...int) (err e // GetBookmark fetches bookmark based on its ID or URL. // Returns the bookmark and boolean whether it's exist or not. func (db *MySQLDatabase) GetBookmark(ctx context.Context, id int, url string) (model.BookmarkDTO, bool, error) { - args := []interface{}{id} - query := `SELECT - id, url, title, excerpt, author, public, - content, html, modified_at, created_at, content <> '' has_content - FROM bookmark WHERE id = ?` - - if url != "" { - query += ` OR url = ?` - args = append(args, url) + // Create the main query builder for bookmark data + sb := sqlbuilder.NewSelectBuilder() + sb.Select( + "id", "url", "title", "excerpt", "author", `public`, "modified_at", + "content", "html", "created_at", "has_content") + sb.From("bookmark") + + // Add conditions + if id != 0 { + sb.Where(sb.Equal("id", id)) + } else if url != "" { + sb.Where(sb.Equal("url", url)) + } else { + return model.BookmarkDTO{}, false, fmt.Errorf("id or url is required") } + // Build the query + query, args := sb.Build() + query = db.ReaderDB().Rebind(query) + // Execute the query book := model.BookmarkDTO{} - if err := db.GetContext(ctx, &book, query, args...); err != nil && err != sql.ErrNoRows { - return book, false, errors.WithStack(err) + err := db.ReaderDB().GetContext(ctx, &book, query, args...) + if err != nil { + if err == sql.ErrNoRows { + return book, false, nil + } + return book, false, fmt.Errorf("failed to get bookmark: %w", err) + } + + // If bookmark exists, fetch its tags + if book.ID != 0 { + // Create query builder for tags + tagSb := sqlbuilder.NewSelectBuilder() + tagSb.Select("t.id", "t.name") + tagSb.From("tag t") + tagSb.JoinWithOption(sqlbuilder.InnerJoin, "bookmark_tag bt", "bt.tag_id = t.id") + tagSb.Where(tagSb.Equal("bt.bookmark_id", book.ID)) + + // Build the query + tagQuery, tagArgs := tagSb.Build() + tagQuery = db.ReaderDB().Rebind(tagQuery) + // Execute the query + tags := []model.TagDTO{} + if err := db.ReaderDB().SelectContext(ctx, &tags, tagQuery, tagArgs...); err != nil && err != sql.ErrNoRows { + return book, false, fmt.Errorf("failed to get tags: %w", err) + } + + book.Tags = tags } - return book, book.ID != 0, nil + return book, true, nil } // CreateAccount saves new account to database. Returns error if any happened. @@ -618,17 +654,17 @@ func (db *MySQLDatabase) CreateAccount(ctx context.Context, account model.Accoun (username, password, owner, config) VALUES (?, ?, ?, ?)`, account.Username, account.Password, account.Owner, account.Config) if err != nil { - return errors.WithStack(err) + return fmt.Errorf("error executing query: %w", err) } id, err := result.LastInsertId() if err != nil { - return errors.WithStack(err) + return fmt.Errorf("error getting last insert id: %w", err) } accountID = id return nil }); err != nil { - return nil, errors.WithStack(err) + return nil, fmt.Errorf("error running transaction: %w", err) } account.ID = model.DBID(accountID) @@ -659,12 +695,12 @@ func (db *MySQLDatabase) UpdateAccount(ctx context.Context, account model.Accoun WHERE id = ?`, account.Username, account.Password, account.Owner, account.Config, account.ID) if err != nil { - return errors.WithStack(err) + return fmt.Errorf("error updating account: %w", err) } rows, err := result.RowsAffected() if err != nil { - return errors.WithStack(err) + return fmt.Errorf("error getting rows affected: %w", err) } if rows == 0 { return ErrNotFound @@ -672,7 +708,7 @@ func (db *MySQLDatabase) UpdateAccount(ctx context.Context, account model.Accoun return nil }); err != nil { - return errors.WithStack(err) + return fmt.Errorf("error running transaction: %w", err) } return nil @@ -721,16 +757,14 @@ func (db *MySQLDatabase) GetAccount(ctx context.Context, id model.DBID) (*model. id, username, password, owner, config FROM account WHERE id = ?`, id, ) - if err != nil && err != sql.ErrNoRows { - return &account, false, errors.WithStack(err) - } - - // Use custom not found error if that's the result of the query - if err == sql.ErrNoRows { - err = ErrNotFound + if err != nil { + if err == sql.ErrNoRows { + return &account, false, ErrNotFound + } + return &account, false, fmt.Errorf("error getting account: %w", err) } - return &account, account.ID != 0, err + return &account, true, nil } // DeleteAccount removes record with matching ID. @@ -738,12 +772,12 @@ func (db *MySQLDatabase) DeleteAccount(ctx context.Context, id model.DBID) error if err := db.withTx(ctx, func(tx *sqlx.Tx) error { result, err := tx.ExecContext(ctx, `DELETE FROM account WHERE id = ?`, id) if err != nil { - return errors.WithStack(fmt.Errorf("error deleting account: %v", err)) + return fmt.Errorf("error deleting account: %w", err) } rows, err := result.RowsAffected() - if err != nil && err != sql.ErrNoRows { - return errors.WithStack(fmt.Errorf("error getting rows affected: %v", err)) + if err != nil { + return fmt.Errorf("error getting rows affected: %w", err) } if rows == 0 { @@ -752,37 +786,87 @@ func (db *MySQLDatabase) DeleteAccount(ctx context.Context, id model.DBID) error return nil }); err != nil { - return errors.WithStack(err) + return fmt.Errorf("error running transaction: %w", err) } return nil } // CreateTags creates new tags from submitted objects. -func (db *MySQLDatabase) CreateTags(ctx context.Context, tags ...model.Tag) error { - query := `INSERT INTO tag (name) VALUES ` - values := []interface{}{} - - for _, t := range tags { - query += "(?)," - values = append(values, t.Name) +func (db *MySQLDatabase) CreateTags(ctx context.Context, tags ...model.Tag) ([]model.Tag, error) { + if len(tags) == 0 { + return []model.Tag{}, nil } - query = query[0 : len(query)-1] + + // Create a slice to hold the created tags + createdTags := make([]model.Tag, len(tags)) + copy(createdTags, tags) if err := db.withTx(ctx, func(tx *sqlx.Tx) error { - stmt, err := tx.Preparex(query) + // For MySQL, we need to insert tags one by one to get their IDs + stmtInsertTag, err := tx.PrepareContext(ctx, "INSERT INTO tag (name) VALUES (?)") if err != nil { - return errors.Wrap(errors.WithStack(err), "error preparing query") + return fmt.Errorf("failed to prepare tag insertion statement: %w", err) } + defer stmtInsertTag.Close() - _, err = stmt.ExecContext(ctx, values...) - if err != nil { - return errors.Wrap(errors.WithStack(err), "error executing query") + // Insert each tag and get its ID + for i, tag := range createdTags { + result, err := stmtInsertTag.ExecContext(ctx, tag.Name) + if err != nil { + return fmt.Errorf("failed to insert tag: %w", err) + } + + // Get the last inserted ID + tagID, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to get last insert ID: %w", err) + } + + createdTags[i].ID = int(tagID) } return nil }); err != nil { - return errors.Wrap(errors.WithStack(err), "error running transaction") + return nil, fmt.Errorf("failed to run tag creation transaction: %w", err) + } + + return createdTags, nil +} + +// CreateTag creates a new tag in database. +func (db *MySQLDatabase) CreateTag(ctx context.Context, tag model.Tag) (model.Tag, error) { + // Use CreateTags to implement this method + createdTags, err := db.CreateTags(ctx, tag) + if err != nil { + return model.Tag{}, err + } + + if len(createdTags) == 0 { + return model.Tag{}, fmt.Errorf("failed to create tag") + } + + return createdTags[0], nil +} + +// RenameTag change the name of a tag. +func (db *MySQLDatabase) RenameTag(ctx context.Context, id int, newName string) error { + sb := sqlbuilder.NewUpdateBuilder() + sb.Update("tag") + sb.Set(sb.Assign("name", newName)) + sb.Where(sb.Equal("id", id)) + + query, args := sb.Build() + query = db.WriterDB().Rebind(query) + + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { + _, err := tx.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to rename tag: %w", err) + } + return nil + }); err != nil { + return err } return nil @@ -790,26 +874,302 @@ func (db *MySQLDatabase) CreateTags(ctx context.Context, tags ...model.Tag) erro // GetTags fetch list of tags and their frequency. func (db *MySQLDatabase) GetTags(ctx context.Context) ([]model.TagDTO, error) { - tags := []model.TagDTO{} - query := `SELECT bt.tag_id id, t.name, COUNT(bt.tag_id) bookmark_count - FROM bookmark_tag bt - LEFT JOIN tag t ON bt.tag_id = t.id - GROUP BY bt.tag_id ORDER BY t.name` + sb := sqlbuilder.MySQL.NewSelectBuilder() + sb.Select("t.id", "t.name", "COUNT(bt.tag_id) AS bookmark_count") + sb.From("tag t") + sb.JoinWithOption(sqlbuilder.LeftJoin, "bookmark_tag bt", "bt.tag_id = t.id") + sb.GroupBy("t.id") + sb.OrderBy("t.name") + + query, args := sb.Build() + query = db.ReaderDB().Rebind(query) - err := db.SelectContext(ctx, &tags, query) + tags := []model.TagDTO{} + err := db.ReaderDB().SelectContext(ctx, &tags, query, args...) if err != nil && err != sql.ErrNoRows { - return nil, errors.WithStack(err) + return nil, fmt.Errorf("failed to get tags: %w", err) } return tags, nil } -// RenameTag change the name of a tag. -func (db *MySQLDatabase) RenameTag(ctx context.Context, id int, newName string) error { - err := db.withTx(ctx, func(tx *sqlx.Tx) error { - _, err := db.ExecContext(ctx, `UPDATE tag SET name = ? WHERE id = ?`, newName, id) - return errors.WithStack(err) +// GetTag fetch a tag by its ID. +func (db *MySQLDatabase) GetTag(ctx context.Context, id int) (model.TagDTO, bool, error) { + sb := sqlbuilder.MySQL.NewSelectBuilder() + sb.Select("t.id", "t.name", "COUNT(bt.tag_id) bookmark_count") + sb.From("tag t") + sb.JoinWithOption(sqlbuilder.LeftJoin, "bookmark_tag bt", "bt.tag_id = t.id") + sb.Where(sb.Equal("t.id", id)) + sb.GroupBy("t.id") + sb.OrderBy("t.name") + + query, args := sb.Build() + query = db.ReaderDB().Rebind(query) + + var tag model.TagDTO + err := db.ReaderDB().GetContext(ctx, &tag, query, args...) + if err == sql.ErrNoRows { + return model.TagDTO{}, false, nil + } + if err != nil { + return model.TagDTO{}, false, fmt.Errorf("failed to get tag: %w", err) + } + + return tag, true, nil +} + +// UpdateTag updates a tag in the database. +func (db *MySQLDatabase) UpdateTag(ctx context.Context, tag model.Tag) error { + sb := sqlbuilder.NewUpdateBuilder() + sb.Update("tag") + sb.Set(sb.Assign("name", tag.Name)) + sb.Where(sb.Equal("id", tag.ID)) + + query, args := sb.Build() + query = db.WriterDB().Rebind(query) + + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { + _, err := tx.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to update tag: %w", err) + } + return nil + }); err != nil { + return err + } + + return nil +} + +// DeleteTag removes a tag from the database. +func (db *MySQLDatabase) DeleteTag(ctx context.Context, id int) error { + // First, check if the tag exists + _, exists, err := db.GetTag(ctx, id) + if err != nil { + return fmt.Errorf("failed to check if tag exists: %w", err) + } + if !exists { + return ErrNotFound + } + + // Delete all bookmark_tag associations + deleteAssocSb := sqlbuilder.NewDeleteBuilder() + deleteAssocSb.DeleteFrom("bookmark_tag") + deleteAssocSb.Where(deleteAssocSb.Equal("tag_id", id)) + + deleteAssocQuery, deleteAssocArgs := deleteAssocSb.Build() + deleteAssocQuery = db.WriterDB().Rebind(deleteAssocQuery) + + // Then, delete the tag itself + deleteTagSb := sqlbuilder.NewDeleteBuilder() + deleteTagSb.DeleteFrom("tag") + deleteTagSb.Where(deleteTagSb.Equal("id", id)) + + deleteTagQuery, deleteTagArgs := deleteTagSb.Build() + deleteTagQuery = db.WriterDB().Rebind(deleteTagQuery) + + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { + // Delete bookmark_tag associations + _, err := tx.ExecContext(ctx, deleteAssocQuery, deleteAssocArgs...) + if err != nil { + return fmt.Errorf("failed to delete tag associations: %w", err) + } + + // Delete the tag + _, err = tx.ExecContext(ctx, deleteTagQuery, deleteTagArgs...) + if err != nil { + return fmt.Errorf("failed to delete tag: %w", err) + } + + return nil + }); err != nil { + return err + } + + return nil +} + +// SaveBookmark saves a single bookmark to database without handling tags. +// It only updates the bookmark data in the database. +func (db *MySQLDatabase) SaveBookmark(ctx context.Context, bookmark model.Bookmark) error { + if bookmark.ID <= 0 { + return fmt.Errorf("bookmark ID must be greater than 0") + } + + // Prepare modified time if not set + if bookmark.ModifiedAt == "" { + bookmark.ModifiedAt = time.Now().UTC().Format(model.DatabaseDateFormat) + } + + // Check URL and title + if bookmark.URL == "" { + return errors.New("URL must not be empty") + } + + if bookmark.Title == "" { + return errors.New("title must not be empty") + } + + // Use sqlbuilder to build the update query + sb := sqlbuilder.NewUpdateBuilder() + sb.Update("bookmark") + sb.Set( + sb.Assign("url", bookmark.URL), + sb.Assign("title", bookmark.Title), + sb.Assign("excerpt", bookmark.Excerpt), + sb.Assign("author", bookmark.Author), + sb.Assign("public", bookmark.Public), + sb.Assign("modified_at", bookmark.ModifiedAt), + sb.Assign("has_content", bookmark.HasContent), + ) + sb.Where(sb.Equal("id", bookmark.ID)) + + query, args := sb.Build() + query = db.WriterDB().Rebind(query) + + return db.withTx(ctx, func(tx *sqlx.Tx) error { + // Update bookmark + _, err := tx.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to update bookmark: %w", err) + } + + return nil }) +} + +func (db *MySQLDatabase) SaveBookmarkTags(ctx context.Context, bookmarkID int, tagIDs []int) error { + return db.withTx(ctx, func(tx *sqlx.Tx) error { + // Prepare statements + stmtDeleteAllBookmarkTags, err := tx.PreparexContext(ctx, `DELETE FROM bookmark_tag WHERE bookmark_id = ?`) + if err != nil { + return fmt.Errorf("failed to prepare delete all bookmark tags statement: %w", err) + } + + stmtInsertBookTag, err := tx.PreparexContext(ctx, `INSERT IGNORE INTO bookmark_tag + (tag_id, bookmark_id) VALUES (?, ?)`) + if err != nil { + return fmt.Errorf("failed to prepare insert book tag statement: %w", err) + } - return errors.WithStack(err) + // Delete all existing tags for this bookmark + _, err = stmtDeleteAllBookmarkTags.ExecContext(ctx, bookmarkID) + if err != nil { + return fmt.Errorf("failed to delete existing bookmark tags: %w", err) + } + + // Insert new tags + for _, tagID := range tagIDs { + _, err := stmtInsertBookTag.ExecContext(ctx, tagID, bookmarkID) + if err != nil { + return fmt.Errorf("failed to insert bookmark tag: %w", err) + } + } + + return nil + }) +} + +// BulkUpdateBookmarkTags updates tags for multiple bookmarks. +// It ensures that all bookmarks and tags exist before proceeding. +func (db *MySQLDatabase) BulkUpdateBookmarkTags(ctx context.Context, bookmarkIDs []int, tagIDs []int) error { + if len(bookmarkIDs) == 0 || len(tagIDs) == 0 { + return nil + } + + // Convert int slices to interface slices for sqlbuilder + bookmarkIDsIface := make([]interface{}, len(bookmarkIDs)) + for i, id := range bookmarkIDs { + bookmarkIDsIface[i] = id + } + + tagIDsIface := make([]interface{}, len(tagIDs)) + for i, id := range tagIDs { + tagIDsIface[i] = id + } + + // Verify all bookmarks exist + bookmarkSb := sqlbuilder.NewSelectBuilder() + bookmarkSb.Select("id") + bookmarkSb.From("bookmark") + bookmarkSb.Where(bookmarkSb.In("id", bookmarkIDsIface...)) + + bookmarkQuery, bookmarkArgs := bookmarkSb.Build() + bookmarkQuery = db.ReaderDB().Rebind(bookmarkQuery) + + var existingBookmarkIDs []int + err := db.ReaderDB().SelectContext(ctx, &existingBookmarkIDs, bookmarkQuery, bookmarkArgs...) + if err != nil { + return fmt.Errorf("failed to check bookmarks: %w", err) + } + + if len(existingBookmarkIDs) != len(bookmarkIDs) { + // Find which bookmarks don't exist + missingBookmarkIDs := model.SliceDifference(bookmarkIDs, existingBookmarkIDs) + return fmt.Errorf("some bookmarks do not exist: %v", missingBookmarkIDs) + } + + // Verify all tags exist + tagSb := sqlbuilder.NewSelectBuilder() + tagSb.Select("id") + tagSb.From("tag") + tagSb.Where(tagSb.In("id", tagIDsIface...)) + + tagQuery, tagArgs := tagSb.Build() + tagQuery = db.ReaderDB().Rebind(tagQuery) + + var existingTagIDs []int + err = db.ReaderDB().SelectContext(ctx, &existingTagIDs, tagQuery, tagArgs...) + if err != nil { + return fmt.Errorf("failed to check tags: %w", err) + } + + if len(existingTagIDs) != len(tagIDs) { + // Find which tags don't exist + missingTagIDs := model.SliceDifference(tagIDs, existingTagIDs) + return fmt.Errorf("some tags do not exist: %v", missingTagIDs) + } + + return db.withTx(ctx, func(tx *sqlx.Tx) error { + // Delete existing bookmark-tag associations + deleteSb := sqlbuilder.NewDeleteBuilder() + deleteSb.DeleteFrom("bookmark_tag") + deleteSb.Where(deleteSb.In("bookmark_id", bookmarkIDsIface...)) + + deleteQuery, deleteArgs := deleteSb.Build() + deleteQuery = tx.Rebind(deleteQuery) + + _, err := tx.ExecContext(ctx, deleteQuery, deleteArgs...) + if err != nil { + return fmt.Errorf("failed to delete existing bookmark tags: %w", err) + } + + // Insert new bookmark-tag associations + if len(tagIDs) > 0 { + // Build values for bulk insert + insertSb := sqlbuilder.NewInsertBuilder() + insertSb.InsertInto("bookmark_tag") + // Fix column order to match database schema + insertSb.Cols("bookmark_id", "tag_id") + + for _, bookmarkID := range bookmarkIDs { + for _, tagID := range tagIDs { + // Match the column order in Values + insertSb.Values(bookmarkID, tagID) + } + } + + insertQuery, insertArgs := insertSb.Build() + // Add MySQL-specific INSERT IGNORE INTO syntax + insertQuery = strings.Replace(insertQuery, "INSERT INTO", "INSERT IGNORE INTO", 1) + insertQuery = tx.Rebind(insertQuery) + + _, err = tx.ExecContext(ctx, insertQuery, insertArgs...) + if err != nil { + return fmt.Errorf("failed to insert bookmark tags: %w", err) + } + } + + return nil + }) } diff --git a/internal/database/mysql_test.go b/internal/database/mysql_test.go index e51cc2739..55efcec68 100644 --- a/internal/database/mysql_test.go +++ b/internal/database/mysql_test.go @@ -18,6 +18,11 @@ func init() { if connString == "" { log.Fatal("mysql tests can't run without a MysQL database, set SHIORI_TEST_MYSQL_URL environment variable") } + + connStringMariaDB := os.Getenv("SHIORI_TEST_MARIADB_URL") + if connStringMariaDB == "" { + log.Fatal("mysql tests can't run without a MariaDB database, set SHIORI_TEST_MARIADB_URL environment variable") + } } func mysqlTestDatabaseFactory(envKey string) testDatabaseFactory { diff --git a/internal/database/pg.go b/internal/database/pg.go index 0ce021d35..386b46747 100644 --- a/internal/database/pg.go +++ b/internal/database/pg.go @@ -4,11 +4,13 @@ import ( "context" "database/sql" "fmt" + "slices" "strconv" "strings" "time" "github.com/go-shiori/shiori/internal/model" + "github.com/huandu/go-sqlbuilder" "github.com/jmoiron/sqlx" "github.com/pkg/errors" @@ -261,6 +263,7 @@ func (db *PGDatabase) SaveBookmarks(ctx context.Context, create bool, bookmarks } tag.ID = int(tagID64) + t.ID = int(tagID64) } if _, err := stmtInsertBookTag.ExecContext(ctx, tag.ID, book.ID); err != nil { @@ -329,21 +332,15 @@ func (db *PGDatabase) GetBookmarks(ctx context.Context, opts model.DBGetBookmark // First we check for * in excluded and included tags, // which means all tags will be excluded and included, respectively. excludeAllTags := false - for _, excludedTag := range opts.ExcludedTags { - if excludedTag == "*" { - excludeAllTags = true - opts.ExcludedTags = []string{} - break - } + if slices.Contains(opts.ExcludedTags, "*") { + excludeAllTags = true + opts.ExcludedTags = []string{} } includeAllTags := false - for _, includedTag := range opts.Tags { - if includedTag == "*" { - includeAllTags = true - opts.Tags = []string{} - break - } + if slices.Contains(opts.Tags, "*") { + includeAllTags = true + opts.Tags = []string{} } // If all tags excluded, we will only show bookmark without tags. @@ -591,23 +588,60 @@ func (db *PGDatabase) DeleteBookmarks(ctx context.Context, ids ...int) (err erro // GetBookmark fetches bookmark based on its ID or URL. // Returns the bookmark and boolean whether it's exist or not. func (db *PGDatabase) GetBookmark(ctx context.Context, id int, url string) (model.BookmarkDTO, bool, error) { - args := []interface{}{id} - query := `SELECT - id, url, title, excerpt, author, public, - content, html, modified_at, created_at, content <> '' has_content - FROM bookmark WHERE id = $1` - - if url != "" { - query += ` OR url = $2` - args = append(args, url) + // Create the main query builder for bookmark data + sb := sqlbuilder.PostgreSQL.NewSelectBuilder() + sb.Select( + "id", "url", "title", "excerpt", "author", `"public"`, "modified_at", + "content", "html", "created_at", "has_content") + sb.From("bookmark") + + // Add conditions + if id != 0 { + sb.Where(sb.Equal("id", id)) + } else if url != "" { + sb.Where(sb.Equal("url", url)) + } else { + return model.BookmarkDTO{}, false, fmt.Errorf("id or url is required") } + // Build the query + query, args := sb.Build() + + // Execute the query book := model.BookmarkDTO{} - if err := db.GetContext(ctx, &book, query, args...); err != nil && err != sql.ErrNoRows { - return book, false, errors.WithStack(err) + + query = db.ReaderDB().Rebind(query) + err := db.ReaderDB().GetContext(ctx, &book, query, args...) + if err != nil { + if err == sql.ErrNoRows { + return book, false, nil + } + return book, false, fmt.Errorf("failed to get bookmark: %w", err) } - return book, book.ID != 0, nil + // If bookmark exists, fetch its tags + if book.ID != 0 { + // Create query builder for tags + tagSb := sqlbuilder.PostgreSQL.NewSelectBuilder() + tagSb.Select("t.id", "t.name") + tagSb.From("tag t") + tagSb.JoinWithOption(sqlbuilder.InnerJoin, "bookmark_tag bt", "bt.tag_id = t.id") + tagSb.Where(tagSb.Equal("bt.bookmark_id", book.ID)) + + // Build the query + tagQuery, tagArgs := tagSb.Build() + tagQuery = db.ReaderDB().Rebind(tagQuery) + + // Execute the query + tags := []model.TagDTO{} + if err := db.ReaderDB().SelectContext(ctx, &tags, tagQuery, tagArgs...); err != nil && err != sql.ErrNoRows { + return book, false, fmt.Errorf("failed to get tags: %w", err) + } + + book.Tags = tags + } + + return book, true, nil } // CreateAccount saves new account to database. Returns error if any happened. @@ -642,11 +676,10 @@ func (db *PGDatabase) CreateAccount(ctx context.Context, account model.Account) return nil }); err != nil { - return nil, fmt.Errorf("error during transaction: %w", err) + return nil, fmt.Errorf("error running transaction: %w", err) } account.ID = model.DBID(accountID) - return &account, nil } @@ -674,12 +707,12 @@ func (db *PGDatabase) UpdateAccount(ctx context.Context, account model.Account) WHERE id = $5`, account.Username, account.Password, account.Owner, account.Config, account.ID) if err != nil { - return errors.WithStack(err) + return fmt.Errorf("error updating account: %w", err) } rows, err := result.RowsAffected() if err != nil { - return errors.WithStack(err) + return fmt.Errorf("error getting rows affected: %w", err) } if rows == 0 { return ErrNotFound @@ -687,7 +720,7 @@ func (db *PGDatabase) UpdateAccount(ctx context.Context, account model.Account) return nil }); err != nil { - return errors.WithStack(err) + return fmt.Errorf("error running transaction: %w", err) } return nil @@ -736,16 +769,14 @@ func (db *PGDatabase) GetAccount(ctx context.Context, id model.DBID) (*model.Acc id, username, password, owner, config FROM account WHERE id = $1`, id, ) - if err != nil && err != sql.ErrNoRows { - return &account, false, errors.WithStack(err) - } - - // Use custom not found error if that's the result of the query - if err == sql.ErrNoRows { - err = ErrNotFound + if err != nil { + if err == sql.ErrNoRows { + return &account, false, ErrNotFound + } + return &account, false, fmt.Errorf("error getting account: %w", err) } - return &account, account.ID != 0, err + return &account, true, nil } // DeleteAccount removes record with matching ID. @@ -753,12 +784,12 @@ func (db *PGDatabase) DeleteAccount(ctx context.Context, id model.DBID) error { if err := db.withTx(ctx, func(tx *sqlx.Tx) error { result, err := tx.ExecContext(ctx, `DELETE FROM account WHERE id = $1`, id) if err != nil { - return errors.WithStack(fmt.Errorf("error deleting account: %v", err)) + return fmt.Errorf("error deleting account: %w", err) } rows, err := result.RowsAffected() - if err != nil && err != sql.ErrNoRows { - return errors.WithStack(fmt.Errorf("error getting rows affected: %v", err)) + if err != nil { + return fmt.Errorf("error getting rows affected: %w", err) } if rows == 0 { @@ -767,24 +798,102 @@ func (db *PGDatabase) DeleteAccount(ctx context.Context, id model.DBID) error { return nil }); err != nil { - return errors.WithStack(err) + return fmt.Errorf("error running transaction: %w", err) } return nil } // CreateTags creates new tags from submitted objects. -func (db *PGDatabase) CreateTags(ctx context.Context, tags ...model.Tag) error { - query := `INSERT INTO tag (name) VALUES (:name)` +func (db *PGDatabase) CreateTags(ctx context.Context, tags ...model.Tag) ([]model.Tag, error) { + if len(tags) == 0 { + return []model.Tag{}, nil + } + + // Create insert builder with RETURNING clause + sb := sqlbuilder.NewInsertBuilder() + sb.InsertInto("tag") + sb.Cols("name") + + // Add values for each tag + for _, tag := range tags { + sb.Values(tag.Name) + } + + // Build query with RETURNING id + query, args := sb.Build() + query = query + " RETURNING id" + query = db.WriterDB().Rebind(query) + + // Create a slice to hold the created tags + createdTags := make([]model.Tag, len(tags)) + copy(createdTags, tags) if err := db.withTx(ctx, func(tx *sqlx.Tx) error { - if _, err := tx.NamedExec(query, tags); err != nil { - return errors.WithStack(err) + // Execute the query and scan the returned IDs + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to execute tag creation query: %w", err) + } + defer rows.Close() + + // Scan the returned IDs into the tags + i := 0 + for rows.Next() { + if i >= len(createdTags) { + break + } + if err := rows.Scan(&createdTags[i].ID); err != nil { + return fmt.Errorf("failed to scan tag ID: %w", err) + } + i++ + } + + if err := rows.Err(); err != nil { + return fmt.Errorf("error iterating over result rows: %w", err) } return nil }); err != nil { - return errors.Wrap(errors.WithStack(err), "error running transaction") + return nil, fmt.Errorf("failed to run tag creation transaction: %w", err) + } + + return createdTags, nil +} + +// CreateTag creates a new tag in database. +func (db *PGDatabase) CreateTag(ctx context.Context, tag model.Tag) (model.Tag, error) { + // Use CreateTags to implement this method + createdTags, err := db.CreateTags(ctx, tag) + if err != nil { + return model.Tag{}, err + } + + if len(createdTags) == 0 { + return model.Tag{}, fmt.Errorf("failed to create tag") + } + + return createdTags[0], nil +} + +// RenameTag change the name of a tag. +func (db *PGDatabase) RenameTag(ctx context.Context, id int, newName string) error { + sb := sqlbuilder.NewUpdateBuilder() + sb.Update("tag") + sb.Set(sb.Assign("name", newName)) + sb.Where(sb.Equal("id", id)) + + query, args := sb.Build() + query = db.WriterDB().Rebind(query) + + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { + _, err := tx.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to rename tag: %w", err) + } + return nil + }); err != nil { + return err } return nil @@ -792,28 +901,263 @@ func (db *PGDatabase) CreateTags(ctx context.Context, tags ...model.Tag) error { // GetTags fetch list of tags and their frequency. func (db *PGDatabase) GetTags(ctx context.Context) ([]model.TagDTO, error) { - tags := []model.TagDTO{} - query := `SELECT bt.tag_id id, t.name, COUNT(bt.tag_id) bookmark_count - FROM bookmark_tag bt - LEFT JOIN tag t ON bt.tag_id = t.id - GROUP BY bt.tag_id, t.name ORDER BY t.name` + sb := sqlbuilder.PostgreSQL.NewSelectBuilder() + sb.Select("t.id", "t.name", "COUNT(bt.tag_id) bookmark_count") + sb.From("tag t") + sb.JoinWithOption(sqlbuilder.LeftJoin, "bookmark_tag bt", "bt.tag_id = t.id") + sb.GroupBy("t.id") + sb.OrderBy("t.name") - err := db.SelectContext(ctx, &tags, query) + query, args := sb.Build() + query = db.ReaderDB().Rebind(query) + + tags := []model.TagDTO{} + err := db.ReaderDB().SelectContext(ctx, &tags, query, args...) if err != nil && err != sql.ErrNoRows { - return nil, errors.WithStack(err) + return nil, fmt.Errorf("failed to get tags: %w", err) } return tags, nil } -// RenameTag change the name of a tag. -func (db *PGDatabase) RenameTag(ctx context.Context, id int, newName string) error { +// GetTag fetch a tag by its ID. +func (db *PGDatabase) GetTag(ctx context.Context, id int) (model.TagDTO, bool, error) { + sb := sqlbuilder.NewSelectBuilder() + sb.Select("t.id", "t.name", "COUNT(bt.tag_id) bookmark_count") + sb.From("tag t") + sb.JoinWithOption(sqlbuilder.LeftJoin, "bookmark_tag bt", "bt.tag_id = t.id") + sb.Where(sb.Equal("t.id", id)) + sb.GroupBy("t.id") + sb.OrderBy("t.name") + + query, args := sb.Build() + query = db.ReaderDB().Rebind(query) + + var tag model.TagDTO + err := db.ReaderDB().GetContext(ctx, &tag, query, args...) + if err == sql.ErrNoRows { + return model.TagDTO{}, false, nil + } + if err != nil { + return model.TagDTO{}, false, fmt.Errorf("failed to get tag: %w", err) + } + + return tag, true, nil +} + +// UpdateTag updates a tag in the database. +func (db *PGDatabase) UpdateTag(ctx context.Context, tag model.Tag) error { + sb := sqlbuilder.NewUpdateBuilder() + sb.Update("tag") + sb.Set(sb.Assign("name", tag.Name)) + sb.Where(sb.Equal("id", tag.ID)) + + query, args := sb.Build() + query = db.WriterDB().Rebind(query) + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { - _, err := db.Exec(`UPDATE tag SET name = $1 WHERE id = $2`, newName, id) - return errors.WithStack(err) + _, err := tx.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to update tag: %w", err) + } + return nil }); err != nil { - return errors.WithStack(err) + return err } return nil } + +// DeleteTag removes a tag from the database. +func (db *PGDatabase) DeleteTag(ctx context.Context, id int) error { + // First, check if the tag exists + _, exists, err := db.GetTag(ctx, id) + if err != nil { + return fmt.Errorf("failed to check if tag exists: %w", err) + } + if !exists { + return ErrNotFound + } + + // Delete all bookmark_tag associations + deleteAssocSb := sqlbuilder.NewDeleteBuilder() + deleteAssocSb.DeleteFrom("bookmark_tag") + deleteAssocSb.Where(deleteAssocSb.Equal("tag_id", id)) + + deleteAssocQuery, deleteAssocArgs := deleteAssocSb.Build() + deleteAssocQuery = db.WriterDB().Rebind(deleteAssocQuery) + + // Then, delete the tag itself + deleteTagSb := sqlbuilder.NewDeleteBuilder() + deleteTagSb.DeleteFrom("tag") + deleteTagSb.Where(deleteTagSb.Equal("id", id)) + + deleteTagQuery, deleteTagArgs := deleteTagSb.Build() + deleteTagQuery = db.WriterDB().Rebind(deleteTagQuery) + + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { + // Delete bookmark_tag associations + _, err := tx.ExecContext(ctx, deleteAssocQuery, deleteAssocArgs...) + if err != nil { + return fmt.Errorf("failed to delete tag associations: %w", err) + } + + // Delete the tag + _, err = tx.ExecContext(ctx, deleteTagQuery, deleteTagArgs...) + if err != nil { + return fmt.Errorf("failed to delete tag: %w", err) + } + + return nil + }); err != nil { + return err + } + + return nil +} + +// SaveBookmark saves a single bookmark to database without handling tags. +// It only updates the bookmark data in the database. +func (db *PGDatabase) SaveBookmark(ctx context.Context, bookmark model.Bookmark) error { + if bookmark.ID <= 0 { + return fmt.Errorf("bookmark ID must be greater than 0") + } + + bookmark.ModifiedAt = time.Now().UTC().Format(model.DatabaseDateFormat) + + // Check URL and title + if bookmark.URL == "" { + return errors.New("URL must not be empty") + } + + if bookmark.Title == "" { + return errors.New("title must not be empty") + } + + // Use sqlbuilder to build the update query + sb := sqlbuilder.NewUpdateBuilder() + sb.Update("bookmark") + sb.Set( + sb.Assign("url", bookmark.URL), + sb.Assign("title", bookmark.Title), + sb.Assign("excerpt", bookmark.Excerpt), + sb.Assign("author", bookmark.Author), + sb.Assign("public", bookmark.Public), + sb.Assign("modified_at", bookmark.ModifiedAt), + sb.Assign("has_content", bookmark.HasContent), + ) + sb.Where(sb.Equal("id", bookmark.ID)) + + query, args := sb.Build() + query = db.WriterDB().Rebind(query) + + return db.withTx(ctx, func(tx *sqlx.Tx) error { + // Update bookmark + _, err := tx.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to update bookmark: %w", err) + } + + return nil + }) +} + +// BulkUpdateBookmarkTags updates tags for multiple bookmarks. +// It ensures that all bookmarks and tags exist before proceeding. +func (db *PGDatabase) BulkUpdateBookmarkTags(ctx context.Context, bookmarkIDs []int, tagIDs []int) error { + if len(bookmarkIDs) == 0 || len(tagIDs) == 0 { + return nil + } + + // Convert int slices to interface slices for sqlbuilder + bookmarkIDsIface := make([]interface{}, len(bookmarkIDs)) + for i, id := range bookmarkIDs { + bookmarkIDsIface[i] = id + } + + tagIDsIface := make([]interface{}, len(tagIDs)) + for i, id := range tagIDs { + tagIDsIface[i] = id + } + + // Verify all bookmarks exist + bookmarkSb := sqlbuilder.NewSelectBuilder() + bookmarkSb.Select("id") + bookmarkSb.From("bookmark") + bookmarkSb.Where(bookmarkSb.In("id", bookmarkIDsIface...)) + + bookmarkQuery, bookmarkArgs := bookmarkSb.Build() + bookmarkQuery = db.ReaderDB().Rebind(bookmarkQuery) + + var existingBookmarkIDs []int + err := db.ReaderDB().SelectContext(ctx, &existingBookmarkIDs, bookmarkQuery, bookmarkArgs...) + if err != nil { + return fmt.Errorf("failed to check bookmarks: %w", err) + } + + if len(existingBookmarkIDs) != len(bookmarkIDs) { + // Find which bookmarks don't exist + missingBookmarkIDs := model.SliceDifference(bookmarkIDs, existingBookmarkIDs) + return fmt.Errorf("some bookmarks do not exist: %v", missingBookmarkIDs) + } + + // Verify all tags exist + tagSb := sqlbuilder.NewSelectBuilder() + tagSb.Select("id") + tagSb.From("tag") + tagSb.Where(tagSb.In("id", tagIDsIface...)) + + tagQuery, tagArgs := tagSb.Build() + tagQuery = db.ReaderDB().Rebind(tagQuery) + + var existingTagIDs []int + err = db.ReaderDB().SelectContext(ctx, &existingTagIDs, tagQuery, tagArgs...) + if err != nil { + return fmt.Errorf("failed to check tags: %w", err) + } + + if len(existingTagIDs) != len(tagIDs) { + // Find which tags don't exist + missingTagIDs := model.SliceDifference(tagIDs, existingTagIDs) + return fmt.Errorf("some tags do not exist: %v", missingTagIDs) + } + + return db.withTx(ctx, func(tx *sqlx.Tx) error { + // Delete existing bookmark-tag associations + deleteSb := sqlbuilder.NewDeleteBuilder() + deleteSb.DeleteFrom("bookmark_tag") + deleteSb.Where(deleteSb.In("bookmark_id", bookmarkIDsIface...)) + + deleteQuery, deleteArgs := deleteSb.Build() + deleteQuery = tx.Rebind(deleteQuery) + + _, err := tx.ExecContext(ctx, deleteQuery, deleteArgs...) + if err != nil { + return fmt.Errorf("failed to delete existing bookmark tags: %w", err) + } + + // Insert new bookmark-tag associations + if len(tagIDs) > 0 { + // Build values for bulk insert + insertSb := sqlbuilder.NewInsertBuilder() + insertSb.InsertInto("bookmark_tag") + insertSb.Cols("bookmark_id", "tag_id") + + for _, bookmarkID := range bookmarkIDs { + for _, tagID := range tagIDs { + insertSb.Values(bookmarkID, tagID) + } + } + + insertQuery, insertArgs := insertSb.Build() + insertQuery = tx.Rebind(insertQuery) + + _, err = tx.ExecContext(ctx, insertQuery, insertArgs...) + if err != nil { + return fmt.Errorf("failed to insert bookmark tags: %w", err) + } + } + + return nil + }) +} diff --git a/internal/database/sqlite.go b/internal/database/sqlite.go index 83e98d775..9279e39a5 100644 --- a/internal/database/sqlite.go +++ b/internal/database/sqlite.go @@ -10,9 +10,12 @@ import ( "time" "github.com/go-shiori/shiori/internal/model" + "github.com/huandu/go-sqlbuilder" "github.com/jmoiron/sqlx" "github.com/pkg/errors" + "slices" + _ "modernc.org/sqlite" ) @@ -78,18 +81,15 @@ func (db *SQLiteDatabase) withTx(ctx context.Context, fn func(tx *sqlx.Tx) error if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } + defer tx.Rollback() // Will be a no-op if tx.Commit() is called - err = fn(tx) - if err != nil { - rbErr := tx.Rollback() - if rbErr != nil { - return fmt.Errorf("error rolling back: %v (original error: %w)", rbErr, err) - } + if err := fn(tx); err != nil { + // Return the error directly without wrapping return fmt.Errorf("transaction failed: %w", err) } if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit transaction: %w", err) + return err } return nil @@ -166,11 +166,6 @@ type bookmarkContent struct { HTML string `db:"html"` } -type tagContent struct { - ID int `db:"bookmark_id"` - model.Tag -} - // DBX returns the underlying sqlx.DB object for writes func (db *SQLiteDatabase) WriterDB() *sqlx.DB { return db.writer.DB @@ -367,6 +362,7 @@ func (db *SQLiteDatabase) SaveBookmarks(ctx context.Context, create bool, bookma } tag.ID = int(tagID64) + t.ID = int(tagID64) } if _, err := stmtInsertBookTag.ExecContext(ctx, tag.ID, book.ID); err != nil { @@ -438,21 +434,15 @@ func (db *SQLiteDatabase) GetBookmarks(ctx context.Context, opts model.DBGetBook // First we check for * in excluded and included tags, // which means all tags will be excluded and included, respectively. excludeAllTags := false - for _, excludedTag := range opts.ExcludedTags { - if excludedTag == "*" { - excludeAllTags = true - opts.ExcludedTags = []string{} - break - } + if slices.Contains(opts.ExcludedTags, "*") { + excludeAllTags = true + opts.ExcludedTags = []string{} } includeAllTags := false - for _, includedTag := range opts.Tags { - if includedTag == "*" { - includeAllTags = true - opts.Tags = []string{} - break - } + if slices.Contains(opts.Tags, "*") { + includeAllTags = true + opts.Tags = []string{} } // If all tags excluded, we will only show bookmark without tags. @@ -552,44 +542,38 @@ func (db *SQLiteDatabase) GetBookmarks(ctx context.Context, opts model.DBGetBook log.Printf("not found content for bookmark %d, but it should be; check DB consistency", book.ID) } } - } // Fetch tags for each bookmark - tags := make([]tagContent, 0, len(bookmarks)) - tagsMap := make(map[int][]model.TagDTO, len(bookmarks)) - - tagsQuery, tagArgs, err := sqlx.In(`SELECT bt.bookmark_id, t.id, t.name - FROM bookmark_tag bt - LEFT JOIN tag t ON bt.tag_id = t.id - WHERE bt.bookmark_id IN (?) - ORDER BY t.name`, bookmarkIds) - tagsQuery = db.reader.Rebind(tagsQuery) - if err != nil { - return nil, fmt.Errorf("failed to delete bookmark and related records: %w", err) + for i, book := range bookmarks { + tags, err := db.getTagsForBookmark(ctx, book.ID) + if err != nil { + return nil, fmt.Errorf("failed to get tags: %w", err) + } + bookmarks[i].Tags = tags } - err = db.reader.Select(&tags, tagsQuery, tagArgs...) + return bookmarks, nil +} + +func (db *SQLiteDatabase) getTagsForBookmark(ctx context.Context, bookmarkID int) ([]model.TagDTO, error) { + sb := sqlbuilder.SQLite.NewSelectBuilder() + sb.Select("t.id", "t.name") + sb.From("bookmark_tag bt") + sb.JoinWithOption(sqlbuilder.LeftJoin, "tag t", "bt.tag_id = t.id") + sb.Where(sb.Equal("bt.bookmark_id", bookmarkID)) + sb.OrderBy("t.name") + + query, args := sb.Build() + query = db.ReaderDB().Rebind(query) + + tags := []model.TagDTO{} + err := db.ReaderDB().SelectContext(ctx, &tags, query, args...) if err != nil && err != sql.ErrNoRows { return nil, fmt.Errorf("failed to get tags: %w", err) } - for _, fetchedTag := range tags { - if tags, found := tagsMap[fetchedTag.ID]; found { - tagsMap[fetchedTag.ID] = append(tags, fetchedTag.Tag.ToDTO()) - } else { - tagsMap[fetchedTag.ID] = []model.TagDTO{fetchedTag.Tag.ToDTO()} - } - } - for i := range bookmarks[:] { - book := &bookmarks[i] - if tags, found := tagsMap[book.ID]; found { - book.Tags = tags - } else { - book.Tags = []model.TagDTO{} - } - } - return bookmarks, nil + return tags, nil } // GetBookmarksCount fetch count of bookmarks based on submitted options. @@ -769,25 +753,61 @@ func (db *SQLiteDatabase) DeleteBookmarks(ctx context.Context, ids ...int) error // GetBookmark fetches bookmark based on its ID or URL. // Returns the bookmark and boolean whether it's exist or not. func (db *SQLiteDatabase) GetBookmark(ctx context.Context, id int, url string) (model.BookmarkDTO, bool, error) { - args := []interface{}{id} - query := `SELECT - b.id, b.url, b.title, b.excerpt, b.author, b.public, b.modified_at, - bc.content, bc.html, b.has_content, b.created_at - FROM bookmark b - LEFT JOIN bookmark_content bc ON bc.docid = b.id - WHERE b.id = ?` - - if url != "" { - query += ` OR b.url = ?` - args = append(args, url) + // Create the main query builder for bookmark data + sb := sqlbuilder.NewSelectBuilder() + sb.Select( + "b.id", "b.url", "b.title", "b.excerpt", "b.author", "b.public", "b.modified_at", + "bc.content", "bc.html", "b.has_content", "b.created_at") + sb.From("bookmark b") + sb.JoinWithOption(sqlbuilder.LeftJoin, "bookmark_content bc", "bc.docid = b.id") + + // Add conditions + if id != 0 { + sb.Where(sb.Equal("b.id", id)) + } else if url != "" { + sb.Where(sb.Equal("b.url", url)) + } else { + return model.BookmarkDTO{}, false, fmt.Errorf("id or url is required") } + // Build the query + query, args := sb.Build() + query = db.ReaderDB().Rebind(query) + + // Execute the query book := model.BookmarkDTO{} - if err := db.reader.GetContext(ctx, &book, query, args...); err != nil && err != sql.ErrNoRows { + err := db.ReaderDB().GetContext(ctx, &book, query, args...) + if err != nil { + if err == sql.ErrNoRows { + return book, false, nil + } return book, false, fmt.Errorf("failed to get bookmark: %w", err) } - return book, book.ID != 0, nil + // If bookmark exists, fetch its tags + if book.ID != 0 { + // Create query builder for tags + tagSb := sqlbuilder.NewSelectBuilder() + tagSb.Select("t.id", "t.name") + tagSb.From("tag t") + tagSb.JoinWithOption(sqlbuilder.InnerJoin, "bookmark_tag bt", "bt.tag_id = t.id") + tagSb.Where(tagSb.Equal("bt.bookmark_id", book.ID)) + + // Build the query + tagQuery, tagArgs := tagSb.Build() + tagQuery = db.ReaderDB().Rebind(tagQuery) + + // Execute the query + tags := []model.TagDTO{} + err = db.ReaderDB().SelectContext(ctx, &tags, tagQuery, tagArgs...) + if err != nil && err != sql.ErrNoRows { + return book, false, fmt.Errorf("failed to get bookmark tags: %w", err) + } + + book.Tags = tags + } + + return book, true, nil } // CreateAccount saves new account to database. Returns error if any happened. @@ -826,7 +846,6 @@ func (db *SQLiteDatabase) CreateAccount(ctx context.Context, account model.Accou } account.ID = model.DBID(accountID) - return &account, nil } @@ -837,7 +856,7 @@ func (db *SQLiteDatabase) UpdateAccount(ctx context.Context, account model.Accou } if err := db.withTx(ctx, func(tx *sqlx.Tx) error { - // Check if username already exists for a different account + // Check if username already exists var exists bool err := tx.GetContext(ctx, &exists, "SELECT EXISTS(SELECT 1 FROM account WHERE username = ? AND id != ?)", @@ -849,17 +868,12 @@ func (db *SQLiteDatabase) UpdateAccount(ctx context.Context, account model.Accou return ErrAlreadyExists } - // Update account - queryString := "UPDATE account SET username = ?, password = ?, owner = ?, config = ? WHERE id = ?" - updateQuery, err := tx.PrepareContext(ctx, queryString) - if err != nil { - return fmt.Errorf("error preparing query: %w", err) - } - - result, err := updateQuery.ExecContext(ctx, + result, err := tx.ExecContext(ctx, `UPDATE account + SET username = ?, password = ?, owner = ?, config = ? + WHERE id = ?`, account.Username, account.Password, account.Owner, account.Config, account.ID) if err != nil { - return fmt.Errorf("error executing query: %w", err) + return fmt.Errorf("error updating account: %w", err) } rows, err := result.RowsAffected() @@ -917,20 +931,18 @@ func (db *SQLiteDatabase) ListAccounts(ctx context.Context, opts model.DBListAcc // Returns the account and boolean whether it's exist or not. func (db *SQLiteDatabase) GetAccount(ctx context.Context, id model.DBID) (*model.Account, bool, error) { account := model.Account{} - err := db.reader.GetContext(ctx, &account, `SELECT + err := db.ReaderDB().GetContext(ctx, &account, `SELECT id, username, password, owner, config FROM account WHERE id = ?`, id, ) - if err != nil && err != sql.ErrNoRows { - return &account, false, errors.WithStack(err) - } - - // Use custom not found error if that's the result of the query - if err == sql.ErrNoRows { - err = ErrNotFound + if err != nil { + if err == sql.ErrNoRows { + return &account, false, ErrNotFound + } + return &account, false, fmt.Errorf("error getting account: %w", err) } - return &account, account.ID != 0, err + return &account, true, nil } // DeleteAccount removes record with matching ID. @@ -938,12 +950,12 @@ func (db *SQLiteDatabase) DeleteAccount(ctx context.Context, id model.DBID) erro if err := db.withTx(ctx, func(tx *sqlx.Tx) error { result, err := tx.ExecContext(ctx, `DELETE FROM account WHERE id = ?`, id) if err != nil { - return errors.WithStack(fmt.Errorf("error deleting account: %v", err)) + return fmt.Errorf("error deleting account: %w", err) } rows, err := result.RowsAffected() - if err != nil && err != sql.ErrNoRows { - return errors.WithStack(fmt.Errorf("error getting rows affected: %v", err)) + if err != nil { + return fmt.Errorf("error getting rows affected: %w", err) } if rows == 0 { @@ -952,37 +964,87 @@ func (db *SQLiteDatabase) DeleteAccount(ctx context.Context, id model.DBID) erro return nil }); err != nil { - return fmt.Errorf("failed to prepare statement: %w", err) + return fmt.Errorf("error running transaction: %w", err) } return nil } // CreateTags creates new tags from submitted objects. -func (db *SQLiteDatabase) CreateTags(ctx context.Context, tags ...model.Tag) error { - query := `INSERT INTO tag (name) VALUES ` - values := []interface{}{} - - for _, t := range tags { - query += "(?)," - values = append(values, t.Name) +func (db *SQLiteDatabase) CreateTags(ctx context.Context, tags ...model.Tag) ([]model.Tag, error) { + if len(tags) == 0 { + return []model.Tag{}, nil } - query = query[0 : len(query)-1] + + // Create a slice to hold the created tags + createdTags := make([]model.Tag, len(tags)) + copy(createdTags, tags) if err := db.withTx(ctx, func(tx *sqlx.Tx) error { - stmt, err := tx.Preparex(query) + // For SQLite, we need to insert tags one by one to get their IDs + stmtInsertTag, err := tx.PrepareContext(ctx, "INSERT INTO tag (name) VALUES (?)") if err != nil { - return fmt.Errorf("failed to prepare tag creation query: %w", err) + return fmt.Errorf("failed to prepare tag insertion statement: %w", err) } + defer stmtInsertTag.Close() - _, err = stmt.ExecContext(ctx, values...) - if err != nil { - return fmt.Errorf("failed to execute tag creation query: %w", err) + // Insert each tag and get its ID + for i, tag := range createdTags { + result, err := stmtInsertTag.ExecContext(ctx, tag.Name) + if err != nil { + return fmt.Errorf("failed to insert tag: %w", err) + } + + // Get the last inserted ID + tagID, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to get last insert ID: %w", err) + } + + createdTags[i].ID = int(tagID) } return nil }); err != nil { - return fmt.Errorf("failed to run tag creation transaction: %w", err) + return nil, fmt.Errorf("failed to run tag creation transaction: %w", err) + } + + return createdTags, nil +} + +// CreateTag creates a new tag in database. +func (db *SQLiteDatabase) CreateTag(ctx context.Context, tag model.Tag) (model.Tag, error) { + // Use CreateTags to implement this method + createdTags, err := db.CreateTags(ctx, tag) + if err != nil { + return model.Tag{}, err + } + + if len(createdTags) == 0 { + return model.Tag{}, fmt.Errorf("failed to create tag") + } + + return createdTags[0], nil +} + +// RenameTag change the name of a tag. +func (db *SQLiteDatabase) RenameTag(ctx context.Context, id int, newName string) error { + sb := sqlbuilder.NewUpdateBuilder() + sb.Update("tag") + sb.Set(sb.Assign("name", newName)) + sb.Where(sb.Equal("id", id)) + + query, args := sb.Build() + query = db.WriterDB().Rebind(query) + + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { + _, err := tx.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to rename tag: %w", err) + } + return nil + }); err != nil { + return err } return nil @@ -990,28 +1052,271 @@ func (db *SQLiteDatabase) CreateTags(ctx context.Context, tags ...model.Tag) err // GetTags fetch list of tags and their frequency. func (db *SQLiteDatabase) GetTags(ctx context.Context) ([]model.TagDTO, error) { - tags := []model.TagDTO{} - query := `SELECT t.id, t.name, COUNT(bt.tag_id) bookmark_count - FROM tag t - LEFT JOIN bookmark_tag bt ON bt.tag_id = t.id - GROUP BY t.id ORDER BY t.name` + sb := sqlbuilder.SQLite.NewSelectBuilder() + sb.Select("t.id", "t.name", "COUNT(bt.tag_id) AS bookmark_count") + sb.From("tag t") + sb.JoinWithOption(sqlbuilder.LeftJoin, "bookmark_tag bt", "bt.tag_id = t.id") + sb.GroupBy("t.id") + sb.OrderBy("t.name") + + query, args := sb.Build() + query = db.ReaderDB().Rebind(query) - err := db.reader.SelectContext(ctx, &tags, query) + tags := []model.TagDTO{} + err := db.ReaderDB().SelectContext(ctx, &tags, query, args...) if err != nil && err != sql.ErrNoRows { - return nil, fmt.Errorf("failed to prepare delete bookmark content statement: %w", err) + return nil, fmt.Errorf("failed to get tags: %w", err) } return tags, nil } -// RenameTag change the name of a tag. -func (db *SQLiteDatabase) RenameTag(ctx context.Context, id int, newName string) error { +// GetTag fetch a tag by its ID. +func (db *SQLiteDatabase) GetTag(ctx context.Context, id int) (model.TagDTO, bool, error) { + sb := sqlbuilder.SQLite.NewSelectBuilder() + sb.Select("t.id", "t.name", "COUNT(bt.tag_id) bookmark_count") + sb.From("tag t") + sb.JoinWithOption(sqlbuilder.LeftJoin, "bookmark_tag bt", "bt.tag_id = t.id") + sb.Where(sb.Equal("t.id", id)) + sb.GroupBy("t.id") + sb.OrderBy("t.name") + + query, args := sb.Build() + query = db.ReaderDB().Rebind(query) + + var tag model.TagDTO + err := db.ReaderDB().GetContext(ctx, &tag, query, args...) + if err == sql.ErrNoRows { + return model.TagDTO{}, false, nil + } + if err != nil { + return model.TagDTO{}, false, fmt.Errorf("failed to get tag: %w", err) + } + + return tag, true, nil +} + +// UpdateTag updates a tag in the database. +func (db *SQLiteDatabase) UpdateTag(ctx context.Context, tag model.Tag) error { + sb := sqlbuilder.NewUpdateBuilder() + sb.Update("tag") + sb.Set(sb.Assign("name", tag.Name)) + sb.Where(sb.Equal("id", tag.ID)) + + query, args := sb.Build() + query = db.WriterDB().Rebind(query) + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { - _, err := tx.ExecContext(ctx, `UPDATE tag SET name = ? WHERE id = ?`, newName, id) + _, err := tx.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to update tag: %w", err) + } + return nil + }); err != nil { return err + } + + return nil +} + +// DeleteTag removes a tag from the database. +func (db *SQLiteDatabase) DeleteTag(ctx context.Context, id int) error { + // First, check if the tag exists + _, exists, err := db.GetTag(ctx, id) + if err != nil { + return fmt.Errorf("failed to check if tag exists: %w", err) + } + if !exists { + return ErrNotFound + } + + // Delete all bookmark_tag associations + deleteAssocSb := sqlbuilder.NewDeleteBuilder() + deleteAssocSb.DeleteFrom("bookmark_tag") + deleteAssocSb.Where(deleteAssocSb.Equal("tag_id", id)) + + deleteAssocQuery, deleteAssocArgs := deleteAssocSb.Build() + deleteAssocQuery = db.WriterDB().Rebind(deleteAssocQuery) + + // Then, delete the tag itself + deleteTagSb := sqlbuilder.NewDeleteBuilder() + deleteTagSb.DeleteFrom("tag") + deleteTagSb.Where(deleteTagSb.Equal("id", id)) + + deleteTagQuery, deleteTagArgs := deleteTagSb.Build() + deleteTagQuery = db.WriterDB().Rebind(deleteTagQuery) + + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { + // Delete bookmark_tag associations + _, err := tx.ExecContext(ctx, deleteAssocQuery, deleteAssocArgs...) + if err != nil { + return fmt.Errorf("failed to delete tag associations: %w", err) + } + + // Delete the tag + _, err = tx.ExecContext(ctx, deleteTagQuery, deleteTagArgs...) + if err != nil { + return fmt.Errorf("failed to delete tag: %w", err) + } + + return nil }); err != nil { - return fmt.Errorf("failed to rename tag: %w", err) + return err } return nil } + +// SaveBookmark saves a single bookmark to database without handling tags. +// It only updates the bookmark data in the database. +func (db *SQLiteDatabase) SaveBookmark(ctx context.Context, bookmark model.Bookmark) error { + if bookmark.ID <= 0 { + return fmt.Errorf("bookmark ID must be greater than 0") + } + + bookmark.ModifiedAt = time.Now().UTC().Format(model.DatabaseDateFormat) + + // Check URL and title + if bookmark.URL == "" { + return errors.New("URL must not be empty") + } + + if bookmark.Title == "" { + return errors.New("title must not be empty") + } + + // Use sqlbuilder to build the update query + sb := sqlbuilder.NewUpdateBuilder() + sb.Update("bookmark") + sb.Set( + sb.Assign("url", bookmark.URL), + sb.Assign("title", bookmark.Title), + sb.Assign("excerpt", bookmark.Excerpt), + sb.Assign("author", bookmark.Author), + sb.Assign("public", bookmark.Public), + sb.Assign("modified_at", bookmark.ModifiedAt), + sb.Assign("has_content", bookmark.HasContent), + ) + sb.Where(sb.Equal("id", bookmark.ID)) + + query, args := sb.Build() + query = db.WriterDB().Rebind(query) + + return db.withTx(ctx, func(tx *sqlx.Tx) error { + // Update bookmark + _, err := tx.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to update bookmark: %w", err) + } + + return nil + }) +} + +// BulkUpdateBookmarkTags updates tags for multiple bookmarks. +// It ensures that all bookmarks and tags exist before proceeding. +func (db *SQLiteDatabase) BulkUpdateBookmarkTags(ctx context.Context, bookmarkIDs []int, tagIDs []int) error { + if len(bookmarkIDs) == 0 || len(tagIDs) == 0 { + return nil + } + + // Convert int slices to any slices for sqlbuilder + bookmarkIDsIface := make([]any, len(bookmarkIDs)) + for i, id := range bookmarkIDs { + bookmarkIDsIface[i] = id + } + + // Verify all bookmarks exist + bookmarkSb := sqlbuilder.NewSelectBuilder() + bookmarkSb.Select("id") + bookmarkSb.From("bookmark") + bookmarkSb.Where(bookmarkSb.In("id", bookmarkIDsIface...)) + + bookmarkQuery, bookmarkArgs := bookmarkSb.Build() + bookmarkQuery = db.ReaderDB().Rebind(bookmarkQuery) + + var existingBookmarkIDs []int + err := db.ReaderDB().SelectContext(ctx, &existingBookmarkIDs, bookmarkQuery, bookmarkArgs...) + if err != nil { + return fmt.Errorf("failed to check bookmarks: %w", err) + } + + if len(existingBookmarkIDs) != len(bookmarkIDs) { + // Find which bookmarks don't exist + missingBookmarkIDs := model.SliceDifference(bookmarkIDs, existingBookmarkIDs) + return fmt.Errorf("some bookmarks do not exist: %v", missingBookmarkIDs) + } + + tagIDsIface := make([]any, len(tagIDs)) + for i, id := range tagIDs { + tagIDsIface[i] = id + } + + // Verify all tags exist + tagSb := sqlbuilder.NewSelectBuilder() + tagSb.Select("id") + tagSb.From("tag") + tagSb.Where(tagSb.In("id", tagIDsIface...)) + + tagQuery, tagArgs := tagSb.Build() + tagQuery = db.ReaderDB().Rebind(tagQuery) + + var existingTagIDs []int + err = db.ReaderDB().SelectContext(ctx, &existingTagIDs, tagQuery, tagArgs...) + if err != nil { + return fmt.Errorf("failed to check tags: %w", err) + } + + if len(existingTagIDs) != len(tagIDs) { + // Find which tags don't exist + missingTagIDs := model.SliceDifference(tagIDs, existingTagIDs) + return fmt.Errorf("some tags do not exist: %v", missingTagIDs) + } + + return db.withTx(ctx, func(tx *sqlx.Tx) error { + // Delete existing bookmark-tag associations + deleteSb := sqlbuilder.NewDeleteBuilder() + deleteSb.DeleteFrom("bookmark_tag") + deleteSb.Where(deleteSb.In("bookmark_id", bookmarkIDsIface...)) + + deleteQuery, deleteArgs := deleteSb.Build() + deleteQuery = tx.Rebind(deleteQuery) + + _, err := tx.ExecContext(ctx, deleteQuery, deleteArgs...) + if err != nil { + return fmt.Errorf("failed to delete existing bookmark tags: %w", err) + } + + // Insert new bookmark-tag associations + if len(tagIDs) > 0 { + // Build insert statement for bookmark tags + insertSb := sqlbuilder.NewInsertBuilder() + // SQLite syntax for INSERT OR IGNORE + insertSb.SQL("INSERT OR IGNORE INTO") + insertSb.SQL("bookmark_tag") + insertSb.SQL("(bookmark_id, tag_id)") + insertSb.SQL("VALUES (?, ?)") + + insertQuery := insertSb.String() + insertQuery = tx.Rebind(insertQuery) + + stmtInsertBookTag, err := tx.PreparexContext(ctx, insertQuery) + if err != nil { + return fmt.Errorf("failed to prepare insert book tag statement: %w", err) + } + defer stmtInsertBookTag.Close() + + // Insert new tags + for _, bookmarkID := range bookmarkIDs { + for _, tagID := range tagIDs { + _, err := stmtInsertBookTag.ExecContext(ctx, bookmarkID, tagID) + if err != nil { + return fmt.Errorf("failed to insert bookmark tag: %w", err) + } + } + } + } + + return nil + }) +} diff --git a/internal/domains/bookmarks.go b/internal/domains/bookmarks.go index 53534337f..37a269098 100644 --- a/internal/domains/bookmarks.go +++ b/internal/domains/bookmarks.go @@ -98,6 +98,21 @@ func (d *BookmarksDomain) UpdateBookmarkCache(ctx context.Context, bookmark mode return &processedBookmark, nil } +// BulkUpdateBookmarkTags updates tags for multiple bookmarks using tag IDs +func (d *BookmarksDomain) BulkUpdateBookmarkTags(ctx context.Context, bookmarkIDs []int, tagIDs []int) error { + if len(bookmarkIDs) == 0 { + return nil + } + + // Call the database method directly + err := d.deps.Database().BulkUpdateBookmarkTags(ctx, bookmarkIDs, tagIDs) + if err != nil { + return fmt.Errorf("failed to update bookmark tags: %w", err) + } + + return nil +} + func NewBookmarksDomain(deps model.Dependencies) *BookmarksDomain { return &BookmarksDomain{ deps: deps, diff --git a/internal/domains/bookmarks_test.go b/internal/domains/bookmarks_test.go index 74e00f2b0..085410790 100644 --- a/internal/domains/bookmarks_test.go +++ b/internal/domains/bookmarks_test.go @@ -207,3 +207,70 @@ func TestBookmarkDomain(t *testing.T) { }) }) } + +func TestBookmarksDomain_BulkUpdateBookmarkTags(t *testing.T) { + ctx := context.Background() + logger := logrus.New() + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + + domain := domains.NewBookmarksDomain(deps) + + t.Run("empty_bookmark_ids", func(t *testing.T) { + err := domain.BulkUpdateBookmarkTags(ctx, []int{}, []int{1, 2, 3}) + require.NoError(t, err) // Should not return an error for empty bookmark IDs + }) + + t.Run("empty_tag_ids", func(t *testing.T) { + err := domain.BulkUpdateBookmarkTags(ctx, []int{1, 2, 3}, []int{}) + require.NoError(t, err) // Should not return an error for empty tag IDs + }) + + t.Run("non_existent_bookmarks", func(t *testing.T) { + err := domain.BulkUpdateBookmarkTags(ctx, []int{999, 1000}, []int{1, 2, 3}) + require.Error(t, err) + }) + + t.Run("successful_update", func(t *testing.T) { + // Create test bookmarks + bookmark1 := testutil.GetValidBookmark() + bookmark2 := testutil.GetValidBookmark() + bookmark2.URL = "https://example.com/different" + + savedBookmarks, err := deps.Database().SaveBookmarks(ctx, true, *bookmark1, *bookmark2) + require.NoError(t, err) + require.Len(t, savedBookmarks, 2) + + // Create test tags + tag1 := model.Tag{Name: "test-tag-1"} + tag2 := model.Tag{Name: "test-tag-2"} + createdTags, err := deps.Database().CreateTags(ctx, tag1, tag2) + require.NoError(t, err) + require.Len(t, createdTags, 2) + + // Get the bookmark and tag IDs + bookmarkIDs := []int{savedBookmarks[0].ID, savedBookmarks[1].ID} + tagIDs := []int{createdTags[0].ID, createdTags[1].ID} + + // Update the bookmarks with the tags + err = domain.BulkUpdateBookmarkTags(ctx, bookmarkIDs, tagIDs) + require.NoError(t, err) + + // Verify the bookmarks have the tags + for _, bookmarkID := range bookmarkIDs { + bookmark, err := domain.GetBookmark(ctx, model.DBID(bookmarkID)) + require.NoError(t, err) + + // Check that the bookmark has both tags + require.Len(t, bookmark.Tags, 2) + + // Verify tag IDs match + tagIDsMap := make(map[int]bool) + for _, tag := range bookmark.Tags { + tagIDsMap[tag.ID] = true + } + + assert.True(t, tagIDsMap[createdTags[0].ID], "Bookmark should have the first tag") + assert.True(t, tagIDsMap[createdTags[1].ID], "Bookmark should have the second tag") + } + }) +} diff --git a/internal/domains/tags.go b/internal/domains/tags.go index 5d23f76c0..66b31aa0a 100644 --- a/internal/domains/tags.go +++ b/internal/domains/tags.go @@ -2,7 +2,9 @@ package domains import ( "context" + "errors" + "github.com/go-shiori/shiori/internal/database" "github.com/go-shiori/shiori/internal/model" ) @@ -25,9 +27,51 @@ func (d *tagsDomain) ListTags(ctx context.Context) ([]model.TagDTO, error) { func (d *tagsDomain) CreateTag(ctx context.Context, tagDTO model.TagDTO) (model.TagDTO, error) { tag := tagDTO.ToTag() - err := d.deps.Database().CreateTags(ctx, tag) + createdTag, err := d.deps.Database().CreateTag(ctx, tag) if err != nil { return model.TagDTO{}, err } - return tag.ToDTO(), nil + + return createdTag.ToDTO(), nil +} + +func (d *tagsDomain) GetTag(ctx context.Context, id int) (model.TagDTO, error) { + tag, exists, err := d.deps.Database().GetTag(ctx, id) + if err != nil { + return model.TagDTO{}, err + } + if !exists { + return model.TagDTO{}, model.ErrNotFound + } + return tag, nil +} + +func (d *tagsDomain) UpdateTag(ctx context.Context, tagDTO model.TagDTO) (model.TagDTO, error) { + tag := tagDTO.ToTag() + err := d.deps.Database().UpdateTag(ctx, tag) + if err != nil { + if errors.Is(err, database.ErrNotFound) { + return model.TagDTO{}, model.ErrNotFound + } + return model.TagDTO{}, err + } + + // Fetch the updated tag to return + updatedTag, err := d.GetTag(ctx, tag.ID) + if err != nil { + return model.TagDTO{}, err + } + + return updatedTag, nil +} + +func (d *tagsDomain) DeleteTag(ctx context.Context, id int) error { + if err := d.deps.Database().DeleteTag(ctx, id); err != nil { + if errors.Is(err, database.ErrNotFound) { + return model.ErrNotFound + } + return err + } + + return nil } diff --git a/internal/domains/tags_test.go b/internal/domains/tags_test.go new file mode 100644 index 000000000..5e3555660 --- /dev/null +++ b/internal/domains/tags_test.go @@ -0,0 +1,159 @@ +package domains_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/go-shiori/shiori/internal/model" + "github.com/go-shiori/shiori/internal/testutil" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Tests for the tagsDomain implementation +func TestTagsDomain(t *testing.T) { + ctx := context.Background() + logger := logrus.New() + + // Setup using the test configuration and dependencies + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + tagsDomain := deps.Domains().Tags() + db := deps.Database() + + // Test ListTags + t.Run("ListTags", func(t *testing.T) { + // Create some test tags first + testTags := []model.Tag{ + {Name: "tag1"}, + {Name: "tag2"}, + } + createdTags, err := db.CreateTags(ctx, testTags...) + require.NoError(t, err) + require.Len(t, createdTags, 2) + + // List the tags + tags, err := tagsDomain.ListTags(ctx) + require.NoError(t, err) + require.Len(t, tags, 2) + + // Verify the tags + assert.Equal(t, "tag1", tags[0].Name) + assert.Equal(t, "tag2", tags[1].Name) + }) + + // Test CreateTag + t.Run("CreateTag", func(t *testing.T) { + // Create a new tag + tagDTO := model.TagDTO{ + Tag: model.Tag{ + Name: "new-tag", + }, + } + + createdTag, err := tagsDomain.CreateTag(ctx, tagDTO) + require.NoError(t, err) + assert.Equal(t, "new-tag", createdTag.Name) + assert.Greater(t, createdTag.ID, 0, "The created tag should have a valid ID") + + // Verify the tag was created in the database + allTags, err := db.GetTags(ctx) + require.NoError(t, err) + require.Len(t, allTags, 3) // 2 from previous test + 1 new + + // Find the created tag in the list + var found bool + for _, tag := range allTags { + if tag.Name == "new-tag" { + found = true + assert.Greater(t, tag.ID, 0, "The tag in the database should have a valid ID") + break + } + } + assert.True(t, found, "The created tag should be found in the database") + }) + + // Test GetTag - Success + t.Run("GetTag_Success", func(t *testing.T) { + // Get all tags to find an ID + allTags, err := db.GetTags(ctx) + require.NoError(t, err) + require.NotEmpty(t, allTags) + + tagID := allTags[0].ID + + // Get the tag by ID + tag, err := tagsDomain.GetTag(ctx, tagID) + require.NoError(t, err) + assert.Equal(t, tagID, tag.ID) + assert.Equal(t, allTags[0].Name, tag.Name) + }) + + // Test GetTag - Not Found + t.Run("GetTag_NotFound", func(t *testing.T) { + // Try to get a non-existent tag + _, err := tagsDomain.GetTag(ctx, 9999) + require.Error(t, err) + assert.Equal(t, model.ErrNotFound, err) + }) + + // Test UpdateTag + t.Run("UpdateTag", func(t *testing.T) { + // Get all tags to find an ID + allTags, err := db.GetTags(ctx) + require.NoError(t, err) + require.NotEmpty(t, allTags) + + tagID := allTags[0].ID + + // Update the tag + tagDTO := model.TagDTO{ + Tag: model.Tag{ + ID: tagID, + Name: "updated-tag", + }, + } + + updatedTag, err := tagsDomain.UpdateTag(ctx, tagDTO) + require.NoError(t, err) + assert.Equal(t, tagID, updatedTag.ID) + assert.Equal(t, "updated-tag", updatedTag.Name) + + // Verify the tag was updated in the database + dbTag, exists, err := db.GetTag(ctx, tagID) + require.NoError(t, err) + require.True(t, exists) + assert.Equal(t, "updated-tag", dbTag.Name) + }) + + // Test DeleteTag + t.Run("DeleteTag", func(t *testing.T) { + // Get all tags to find an ID + allTags, err := db.GetTags(ctx) + require.NoError(t, err) + require.NotEmpty(t, allTags) + + tagID := allTags[1].ID + + // Delete the tag + err = tagsDomain.DeleteTag(ctx, tagID) + require.NoError(t, err) + + // Verify the tag was deleted from the database + _, exists, err := db.GetTag(ctx, tagID) + require.NoError(t, err) + require.False(t, exists) + }) + + // Test DeleteTag - Not Found + t.Run("DeleteTag_NotFound", func(t *testing.T) { + // Try to delete a non-existent tag + err := tagsDomain.DeleteTag(ctx, 9999) + require.Error(t, err) + // Use errors.Is to check if the error is or wraps model.ErrNotFound + assert.True(t, errors.Is(err, model.ErrNotFound) || strings.Contains(err.Error(), "not found"), + "Expected error to be or contain 'not found', got: %v", err) + }) +} diff --git a/internal/http/handlers/api/v1/bookmarks.go b/internal/http/handlers/api/v1/bookmarks.go index f8c8c7178..68d24eb00 100644 --- a/internal/http/handlers/api/v1/bookmarks.go +++ b/internal/http/handlers/api/v1/bookmarks.go @@ -160,3 +160,62 @@ func HandleUpdateCache(deps model.Dependencies, c model.WebContext) { response.Send(c, http.StatusOK, bookmarks) } + +type bulkUpdateBookmarkTagsPayload struct { + BookmarkIDs []int `json:"bookmark_ids" validate:"required"` + TagIDs []int `json:"tag_ids" validate:"required"` +} + +func (p *bulkUpdateBookmarkTagsPayload) IsValid() error { + if len(p.BookmarkIDs) == 0 { + return fmt.Errorf("bookmark_ids should not be empty") + } + if len(p.TagIDs) == 0 { + return fmt.Errorf("tag_ids should not be empty") + } + return nil +} + +// HandleBulkUpdateBookmarkTags updates the tags for multiple bookmarks +// +// @Summary Bulk update tags for multiple bookmarks. +// @Tags Auth +// @securityDefinitions.apikey ApiKeyAuth +// @Param payload body bulkUpdateBookmarkTagsPayload true "Bulk Update Bookmark Tags Payload" +// @Produce json +// @Success 200 {object} []model.BookmarkDTO +// @Failure 403 {object} nil "Token not provided/invalid" +// @Failure 400 {object} nil "Invalid request payload" +// @Failure 404 {object} nil "No bookmarks found" +// @Router /api/v1/bookmarks/bulk/tags [put] +func HandleBulkUpdateBookmarkTags(deps model.Dependencies, c model.WebContext) { + if err := middleware.RequireLoggedInUser(deps, c); err != nil { + response.SendError(c, http.StatusForbidden, err.Error(), nil) + return + } + + // Parse request payload + var payload bulkUpdateBookmarkTagsPayload + if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil { + response.SendError(c, http.StatusBadRequest, "Invalid request payload", nil) + return + } + + if err := payload.IsValid(); err != nil { + response.SendError(c, http.StatusBadRequest, err.Error(), nil) + return + } + + // Use the domain method to update bookmark tags + err := deps.Domains().Bookmarks().BulkUpdateBookmarkTags(c.Request().Context(), payload.BookmarkIDs, payload.TagIDs) + if err != nil { + if err == model.ErrBookmarkNotFound { + response.SendError(c, http.StatusNotFound, "No bookmarks found", nil) + return + } + response.SendError(c, http.StatusInternalServerError, "Failed to update bookmarks", nil) + return + } + + response.Send(c, http.StatusOK, nil) +} diff --git a/internal/http/handlers/api/v1/bookmarks_test.go b/internal/http/handlers/api/v1/bookmarks_test.go index ef4d64b6b..7e78e49d2 100644 --- a/internal/http/handlers/api/v1/bookmarks_test.go +++ b/internal/http/handlers/api/v1/bookmarks_test.go @@ -2,11 +2,14 @@ package api_v1 import ( "context" + "encoding/json" + "io" "net/http" "strconv" "testing" "time" + "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" @@ -192,3 +195,124 @@ func TestHandleUpdateCache(t *testing.T) { require.True(t, updatedBookmark.HasArchive) }) } + +func TestHandleUpdateBookmarkTags(t *testing.T) { + ctx := context.Background() + logger := logrus.New() + logger.SetOutput(io.Discard) + + t.Run("requires_authentication", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + w := testutil.PerformRequest( + deps, + HandleBulkUpdateBookmarkTags, + "PUT", + "/api/v1/bookmarks/tags", + ) + require.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("invalid_json_payload", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + w := testutil.PerformRequest( + deps, + HandleBulkUpdateBookmarkTags, + "PUT", + "/api/v1/bookmarks/tags", + testutil.WithFakeUser(), + testutil.WithBody("invalid json"), + ) + require.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("empty_ids", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + payload := map[string]interface{}{ + "ids": []int{}, + "tags": []model.Tag{{Name: "test"}}, + } + body, _ := json.Marshal(payload) + w := testutil.PerformRequest( + deps, + HandleBulkUpdateBookmarkTags, + "PUT", + "/api/v1/bookmarks/tags", + testutil.WithFakeUser(), + testutil.WithBody(string(body)), + ) + require.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("empty_tags", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + payload := map[string]interface{}{ + "ids": []int{1}, + "tags": []model.Tag{}, + } + body, _ := json.Marshal(payload) + w := testutil.PerformRequest( + deps, + HandleBulkUpdateBookmarkTags, + "PUT", + "/api/v1/bookmarks/tags", + testutil.WithFakeUser(), + testutil.WithBody(string(body)), + ) + require.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("bookmark_not_found", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + payload := map[string]interface{}{ + "ids": []int{999}, + "tags": []model.Tag{{Name: "test"}}, + } + body, _ := json.Marshal(payload) + w := testutil.PerformRequest( + deps, + HandleBulkUpdateBookmarkTags, + "PUT", + "/api/v1/bookmarks/tags", + testutil.WithFakeUser(), + testutil.WithBody(string(body)), + ) + require.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("successful_update", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + + // Create a bookmark first + bookmark := testutil.GetValidBookmark() + savedBookmark, err := deps.Database().SaveBookmarks(ctx, true, *bookmark) + require.NoError(t, err) + require.Len(t, savedBookmark, 1) + + // Create a tag + tag := model.TagDTO{Tag: model.Tag{Name: "newtag"}} + createdTag, err := deps.Database().CreateTag(ctx, tag.Tag) + require.NoError(t, err) + + // Update the bookmark tags + payload := map[string]interface{}{ + "bookmark_ids": []int{savedBookmark[0].ID}, + "tag_ids": []int{createdTag.ID}, + } + body, _ := json.Marshal(payload) + w := testutil.PerformRequest( + deps, + HandleBulkUpdateBookmarkTags, + "PUT", + "/api/v1/bookmarks/tags", + testutil.WithFakeUser(), + testutil.WithBody(string(body)), + ) + t.Log(w.Body.String()) + require.Equal(t, http.StatusOK, w.Code) + + // Verify the response + response, err := testutil.NewTestResponseFromBytes(w.Body.Bytes()) + require.NoError(t, err) + response.AssertOk(t) + }) +} diff --git a/internal/http/handlers/api/v1/tags.go b/internal/http/handlers/api/v1/tags.go index c362fef3c..deb8bf829 100644 --- a/internal/http/handlers/api/v1/tags.go +++ b/internal/http/handlers/api/v1/tags.go @@ -1,7 +1,9 @@ package api_v1 import ( + "encoding/json" "net/http" + "strconv" "github.com/go-shiori/shiori/internal/http/middleware" "github.com/go-shiori/shiori/internal/http/response" @@ -13,7 +15,7 @@ import ( // @Tags Tags // @securityDefinitions.apikey ApiKeyAuth // @Produce json -// @Success 200 {array} model.Tag +// @Success 200 {array} model.TagDTO // @Failure 403 {object} nil "Authentication required" // @Failure 500 {object} nil "Internal server error" // @Router /api/v1/tags [get] @@ -31,3 +33,170 @@ func HandleListTags(deps model.Dependencies, c model.WebContext) { response.Send(c, http.StatusOK, tags) } + +// @Summary Get tag +// @Description Get a tag by ID +// @Tags Tags +// @securityDefinitions.apikey ApiKeyAuth +// @Produce json +// @Param id path int true "Tag ID" +// @Success 200 {object} model.TagDTO +// @Failure 403 {object} nil "Authentication required" +// @Failure 404 {object} nil "Tag not found" +// @Failure 500 {object} nil "Internal server error" +// @Router /api/v1/tags/{id} [get] +func HandleGetTag(deps model.Dependencies, c model.WebContext) { + if err := middleware.RequireLoggedInUser(deps, c); err != nil { + return + } + + idParam := c.Request().PathValue("id") + id, err := strconv.Atoi(idParam) + if err != nil { + response.SendError(c, http.StatusBadRequest, "Invalid tag ID", nil) + return + } + + tag, err := deps.Domains().Tags().GetTag(c.Request().Context(), id) + if err != nil { + if err == model.ErrNotFound { + response.NotFound(c) + return + } + deps.Logger().WithError(err).Error("failed to get tag") + response.SendInternalServerError(c) + return + } + + response.Send(c, http.StatusOK, tag) +} + +// @Summary Create tag +// @Description Create a new tag +// @Tags Tags +// @securityDefinitions.apikey ApiKeyAuth +// @Accept json +// @Produce json +// @Param tag body model.TagDTO true "Tag data" +// @Success 201 {object} model.TagDTO +// @Failure 400 {object} nil "Invalid request" +// @Failure 403 {object} nil "Authentication required" +// @Failure 500 {object} nil "Internal server error" +// @Router /api/v1/tags [post] +func HandleCreateTag(deps model.Dependencies, c model.WebContext) { + if err := middleware.RequireLoggedInUser(deps, c); err != nil { + return + } + + var tag model.TagDTO + err := json.NewDecoder(c.Request().Body).Decode(&tag) + if err != nil { + response.SendError(c, http.StatusBadRequest, "Invalid request body", nil) + return + } + + if tag.Name == "" { + response.SendError(c, http.StatusBadRequest, "Tag name is required", nil) + return + } + + createdTag, err := deps.Domains().Tags().CreateTag(c.Request().Context(), tag) + if err != nil { + deps.Logger().WithError(err).Error("failed to create tag") + response.SendInternalServerError(c) + return + } + + response.Send(c, http.StatusCreated, createdTag) +} + +// @Summary Update tag +// @Description Update an existing tag +// @Tags Tags +// @securityDefinitions.apikey ApiKeyAuth +// @Accept json +// @Produce json +// @Param id path int true "Tag ID" +// @Param tag body model.TagDTO true "Tag data" +// @Success 200 {object} model.TagDTO +// @Failure 400 {object} nil "Invalid request" +// @Failure 403 {object} nil "Authentication required" +// @Failure 404 {object} nil "Tag not found" +// @Failure 500 {object} nil "Internal server error" +// @Router /api/v1/tags/{id} [put] +func HandleUpdateTag(deps model.Dependencies, c model.WebContext) { + if err := middleware.RequireLoggedInUser(deps, c); err != nil { + return + } + + idParam := c.Request().PathValue("id") + id, err := strconv.Atoi(idParam) + if err != nil { + response.SendError(c, http.StatusBadRequest, "Invalid tag ID", nil) + return + } + + var tag model.TagDTO + err = json.NewDecoder(c.Request().Body).Decode(&tag) + if err != nil { + response.SendError(c, http.StatusBadRequest, "Invalid request body", nil) + return + } + + if tag.Name == "" { + response.SendError(c, http.StatusBadRequest, "Tag name is required", nil) + return + } + + // Ensure the ID in the URL matches the ID in the body + tag.ID = id + + updatedTag, err := deps.Domains().Tags().UpdateTag(c.Request().Context(), tag) + if err != nil { + if err == model.ErrNotFound { + response.NotFound(c) + return + } + deps.Logger().WithError(err).Error("failed to update tag") + response.SendInternalServerError(c) + return + } + + response.Send(c, http.StatusOK, updatedTag) +} + +// @Summary Delete tag +// @Description Delete a tag +// @Tags Tags +// @securityDefinitions.apikey ApiKeyAuth +// @Param id path int true "Tag ID" +// @Success 204 {object} nil +// @Failure 403 {object} nil "Authentication required" +// @Failure 404 {object} nil "Tag not found" +// @Failure 500 {object} nil "Internal server error" +// @Router /api/v1/tags/{id} [delete] +func HandleDeleteTag(deps model.Dependencies, c model.WebContext) { + if err := middleware.RequireLoggedInAdmin(deps, c); err != nil { + return + } + + idParam := c.Request().PathValue("id") + id, err := strconv.Atoi(idParam) + if err != nil { + response.SendError(c, http.StatusBadRequest, "Invalid tag ID", nil) + return + } + + err = deps.Domains().Tags().DeleteTag(c.Request().Context(), id) + if err != nil { + if err == model.ErrNotFound { + response.NotFound(c) + return + } + deps.Logger().WithError(err).Error("failed to delete tag") + response.SendInternalServerError(c) + return + } + + response.Send(c, http.StatusNoContent, nil) +} diff --git a/internal/http/handlers/api/v1/tags_test.go b/internal/http/handlers/api/v1/tags_test.go index 47034bf4f..19c112b59 100644 --- a/internal/http/handlers/api/v1/tags_test.go +++ b/internal/http/handlers/api/v1/tags_test.go @@ -2,7 +2,9 @@ package api_v1 import ( "context" + "encoding/json" "net/http" + "strconv" "testing" "github.com/go-shiori/shiori/internal/model" @@ -17,23 +19,20 @@ func TestHandleListTags(t *testing.T) { t.Run("requires authentication", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) - c, w := testutil.NewTestWebContext() - HandleListTags(deps, c) + w := testutil.PerformRequest(deps, HandleListTags, "GET", "/api/v1/tags") require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("returns tags list", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) - // Create test tag - _, err := deps.Domains().Tags().CreateTag(ctx, model.TagDTO{ - Tag: model.Tag{Name: "test-tag"}, - }) + // Create a test tag + tag := model.Tag{Name: "test-tag"} + createdTags, err := deps.Database().CreateTags(ctx, tag) require.NoError(t, err) + require.Len(t, createdTags, 1) - t.Log(deps.Database().GetTags(ctx)) - - w := testutil.PerformRequest(deps, HandleListTags, "GET", "/api/v1/tags", testutil.WithFakeAccount(true)) + w := testutil.PerformRequest(deps, HandleListTags, "GET", "/api/v1/tags", testutil.WithFakeUser()) require.Equal(t, http.StatusOK, w.Code) response, err := testutil.NewTestResponseFromReader(w.Body) @@ -42,3 +41,333 @@ func TestHandleListTags(t *testing.T) { response.AssertMessageIsNotEmptyList(t) }) } + +func TestHandleGetTag(t *testing.T) { + logger := logrus.New() + ctx := context.Background() + + t.Run("requires authentication", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + w := testutil.PerformRequest( + deps, + HandleGetTag, + "GET", + "/api/v1/tags/1", + testutil.WithRequestPathValue("id", "1"), + ) + require.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("invalid tag id", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + w := testutil.PerformRequest( + deps, + HandleGetTag, + "GET", + "/api/v1/tags/invalid", + testutil.WithFakeUser(), + testutil.WithRequestPathValue("id", "invalid"), + ) + require.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("tag not found", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + w := testutil.PerformRequest( + deps, + HandleGetTag, + "GET", + "/api/v1/tags/999", + testutil.WithFakeUser(), + testutil.WithRequestPathValue("id", "999"), + ) + require.Equal(t, http.StatusNotFound, w.Code) + }) + + t.Run("success", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + + // Create a test tag + tag := model.Tag{Name: "test-tag"} + createdTags, err := deps.Database().CreateTags(ctx, tag) + require.NoError(t, err) + require.Len(t, createdTags, 1) + + tagID := createdTags[0].ID + w := testutil.PerformRequest( + deps, + HandleGetTag, + "GET", + "/api/v1/tags/"+strconv.Itoa(tagID), + testutil.WithFakeUser(), + testutil.WithRequestPathValue("id", strconv.Itoa(tagID)), + ) + require.Equal(t, http.StatusOK, w.Code) + + response, err := testutil.NewTestResponseFromReader(w.Body) + require.NoError(t, err) + response.AssertOk(t) + + // Verify the tag data + var tagDTO model.TagDTO + responseData, err := json.Marshal(response.Response.GetMessage()) + require.NoError(t, err) + err = json.Unmarshal(responseData, &tagDTO) + require.NoError(t, err) + require.Equal(t, tagID, tagDTO.ID) + require.Equal(t, "test-tag", tagDTO.Name) + }) +} + +func TestHandleCreateTag(t *testing.T) { + logger := logrus.New() + ctx := context.Background() + + t.Run("requires authentication", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + w := testutil.PerformRequest(deps, HandleCreateTag, "POST", "/api/v1/tags") + require.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("invalid json payload", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + w := testutil.PerformRequest( + deps, + HandleCreateTag, + "POST", + "/api/v1/tags", + testutil.WithFakeUser(), + testutil.WithBody("invalid json"), + ) + require.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("empty tag name", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + w := testutil.PerformRequest( + deps, + HandleCreateTag, + "POST", + "/api/v1/tags", + testutil.WithFakeUser(), + testutil.WithBody(`{"name": ""}`), + ) + require.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("successful creation", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + w := testutil.PerformRequest( + deps, + HandleCreateTag, + "POST", + "/api/v1/tags", + testutil.WithFakeUser(), + testutil.WithBody(`{"name": "new-test-tag"}`), + ) + require.Equal(t, http.StatusCreated, w.Code) + + response, err := testutil.NewTestResponseFromReader(w.Body) + require.NoError(t, err) + response.AssertOk(t) + + // Verify the created tag + var tagDTO model.TagDTO + responseData, err := json.Marshal(response.Response.GetMessage()) + require.NoError(t, err) + err = json.Unmarshal(responseData, &tagDTO) + require.NoError(t, err) + require.Greater(t, tagDTO.ID, 0) + require.Equal(t, "new-test-tag", tagDTO.Name) + }) +} + +func TestHandleUpdateTag(t *testing.T) { + logger := logrus.New() + ctx := context.Background() + + t.Run("requires authentication", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + w := testutil.PerformRequest( + deps, + HandleUpdateTag, + "PUT", + "/api/v1/tags/1", + testutil.WithRequestPathValue("id", "1"), + ) + require.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("invalid tag id", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + w := testutil.PerformRequest( + deps, + HandleUpdateTag, + "PUT", + "/api/v1/tags/invalid", + testutil.WithFakeUser(), + testutil.WithRequestPathValue("id", "invalid"), + ) + require.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("invalid json payload", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + w := testutil.PerformRequest( + deps, + HandleUpdateTag, + "PUT", + "/api/v1/tags/1", + testutil.WithFakeUser(), + testutil.WithRequestPathValue("id", "1"), + testutil.WithBody("invalid json"), + ) + require.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("empty tag name", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + w := testutil.PerformRequest( + deps, + HandleUpdateTag, + "PUT", + "/api/v1/tags/1", + testutil.WithFakeUser(), + testutil.WithRequestPathValue("id", "1"), + testutil.WithBody(`{"name": ""}`), + ) + require.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("tag not found", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + w := testutil.PerformRequest( + deps, + HandleUpdateTag, + "PUT", + "/api/v1/tags/999", + testutil.WithFakeUser(), + testutil.WithRequestPathValue("id", "999"), + testutil.WithBody(`{"name": "updated-tag"}`), + ) + require.Equal(t, http.StatusNotFound, w.Code) + }) + + t.Run("successful update", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + + // Create a test tag + tag := model.Tag{Name: "test-tag-for-update"} + createdTags, err := deps.Database().CreateTags(ctx, tag) + require.NoError(t, err) + require.Len(t, createdTags, 1) + + tagID := createdTags[0].ID + w := testutil.PerformRequest( + deps, + HandleUpdateTag, + "PUT", + "/api/v1/tags/"+strconv.Itoa(tagID), + testutil.WithFakeUser(), + testutil.WithRequestPathValue("id", strconv.Itoa(tagID)), + testutil.WithBody(`{"name": "updated-test-tag"}`), + ) + require.Equal(t, http.StatusOK, w.Code) + + response, err := testutil.NewTestResponseFromReader(w.Body) + require.NoError(t, err) + response.AssertOk(t) + + // Verify the updated tag + var tagDTO model.TagDTO + responseData, err := json.Marshal(response.Response.GetMessage()) + require.NoError(t, err) + err = json.Unmarshal(responseData, &tagDTO) + require.NoError(t, err) + require.Equal(t, tagID, tagDTO.ID) + require.Equal(t, "updated-test-tag", tagDTO.Name) + }) +} + +func TestHandleDeleteTag(t *testing.T) { + logger := logrus.New() + ctx := context.Background() + + t.Run("requires authentication", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + w := testutil.PerformRequest( + deps, + HandleDeleteTag, + "DELETE", + "/api/v1/tags/1", + testutil.WithRequestPathValue("id", "1"), + ) + require.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("requires admin privileges", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + w := testutil.PerformRequest( + deps, + HandleDeleteTag, + "DELETE", + "/api/v1/tags/1", + testutil.WithFakeUser(), // Regular user, not admin + testutil.WithRequestPathValue("id", "1"), + ) + require.Equal(t, http.StatusForbidden, w.Code) + }) + + t.Run("invalid tag id", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + w := testutil.PerformRequest( + deps, + HandleDeleteTag, + "DELETE", + "/api/v1/tags/invalid", + testutil.WithFakeAdmin(), + testutil.WithRequestPathValue("id", "invalid"), + ) + require.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("tag not found", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + + w := testutil.PerformRequest( + deps, + HandleDeleteTag, + "DELETE", + "/api/v1/tags/999", + testutil.WithFakeAdmin(), + testutil.WithRequestPathValue("id", "999"), + ) + require.Equal(t, http.StatusNotFound, w.Code) + }) + + t.Run("successful deletion", func(t *testing.T) { + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + + // Create a test tag + tag := model.Tag{Name: "test-tag-for-deletion"} + createdTags, err := deps.Database().CreateTags(ctx, tag) + require.NoError(t, err) + require.Len(t, createdTags, 1) + + tagID := createdTags[0].ID + w := testutil.PerformRequest( + deps, + HandleDeleteTag, + "DELETE", + "/api/v1/tags/"+strconv.Itoa(tagID), + testutil.WithFakeAdmin(), + testutil.WithRequestPathValue("id", strconv.Itoa(tagID)), + ) + require.Equal(t, http.StatusNoContent, w.Code) + + // Verify the tag was deleted + _, exists, err := deps.Database().GetTag(ctx, tagID) + require.NoError(t, err) + require.False(t, exists) + }) +} diff --git a/internal/http/server.go b/internal/http/server.go index 42aca9a81..0c97fe2e6 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -138,6 +138,22 @@ func (s *HttpServer) Setup(cfg *config.Config, deps *dependencies.Dependencies) api_v1.HandleListTags, globalMiddleware..., )) + s.mux.HandleFunc("GET /api/v1/tags/{id}", ToHTTPHandler(deps, + api_v1.HandleGetTag, + globalMiddleware..., + )) + s.mux.HandleFunc("POST /api/v1/tags", ToHTTPHandler(deps, + api_v1.HandleCreateTag, + globalMiddleware..., + )) + s.mux.HandleFunc("PUT /api/v1/tags/{id}", ToHTTPHandler(deps, + api_v1.HandleUpdateTag, + globalMiddleware..., + )) + s.mux.HandleFunc("DELETE /api/v1/tags/{id}", ToHTTPHandler(deps, + api_v1.HandleDeleteTag, + globalMiddleware..., + )) // Bookmarks s.mux.HandleFunc("PUT /api/v1/bookmarks/cache", ToHTTPHandler(deps, api_v1.HandleUpdateCache, @@ -147,6 +163,10 @@ func (s *HttpServer) Setup(cfg *config.Config, deps *dependencies.Dependencies) api_v1.HandleBookmarkReadable, globalMiddleware..., )) + s.mux.HandleFunc("PUT /api/v1/bookmarks/bulk/tags", ToHTTPHandler(deps, + api_v1.HandleBulkUpdateBookmarkTags, + globalMiddleware..., + )) s.server = &http.Server{ Addr: fmt.Sprintf("%s%d", cfg.Http.Address, cfg.Http.Port), diff --git a/internal/model/bookmark.go b/internal/model/bookmark.go index 196d9bd1a..77d8c050d 100644 --- a/internal/model/bookmark.go +++ b/internal/model/bookmark.go @@ -5,6 +5,19 @@ import ( "strconv" ) +// Bookmark is the database representation of a bookmark +type Bookmark struct { + ID int `db:"id"` + URL string `db:"url"` + Title string `db:"title"` + Excerpt string `db:"excerpt"` + Author string `db:"author"` + Public int `db:"public"` + CreatedAt string `db:"created_at"` + ModifiedAt string `db:"modified_at"` + HasContent bool `db:"has_content"` +} + // BookmarkDTO is the bookmark object representation in database and the data transfer object // at the same time, pending a refactor to two separate object to represent each role. type BookmarkDTO struct { @@ -27,6 +40,37 @@ type BookmarkDTO struct { CreateEbook bool `json:"create_ebook"` // TODO: migrate outside the DTO } +// ToBookmark converts a BookmarkDTO to a Bookmark +func (dto *BookmarkDTO) ToBookmark() Bookmark { + return Bookmark{ + ID: dto.ID, + URL: dto.URL, + Title: dto.Title, + Excerpt: dto.Excerpt, + Author: dto.Author, + Public: dto.Public, + CreatedAt: dto.CreatedAt, + ModifiedAt: dto.ModifiedAt, + HasContent: dto.HasContent, + } +} + +// ToDTO converts a Bookmark to a BookmarkDTO +func (b *Bookmark) ToDTO() BookmarkDTO { + return BookmarkDTO{ + ID: b.ID, + URL: b.URL, + Title: b.Title, + Excerpt: b.Excerpt, + Author: b.Author, + Public: b.Public, + CreatedAt: b.CreatedAt, + ModifiedAt: b.ModifiedAt, + HasContent: b.HasContent, + Tags: []TagDTO{}, + } +} + // GetTumnbailPath returns the relative path to the thumbnail of a bookmark in the filesystem func GetThumbnailPath(bookmark *BookmarkDTO) string { return filepath.Join("thumb", strconv.Itoa(bookmark.ID)) diff --git a/internal/model/bookmark_test.go b/internal/model/bookmark_test.go new file mode 100644 index 000000000..e026d9710 --- /dev/null +++ b/internal/model/bookmark_test.go @@ -0,0 +1,213 @@ +package model + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBookmarkToDTO(t *testing.T) { + // Create a test bookmark + bookmark := Bookmark{ + ID: 123, + URL: "https://example.com", + Title: "Example Title", + Excerpt: "This is an excerpt", + Author: "John Doe", + Public: 1, + CreatedAt: "2023-01-01 12:00:00", + ModifiedAt: "2023-01-02 12:00:00", + HasContent: true, + } + + // Convert to DTO + dto := bookmark.ToDTO() + + // Verify all fields are correctly transferred + assert.Equal(t, bookmark.ID, dto.ID, "ID should match") + assert.Equal(t, bookmark.URL, dto.URL, "URL should match") + assert.Equal(t, bookmark.Title, dto.Title, "Title should match") + assert.Equal(t, bookmark.Excerpt, dto.Excerpt, "Excerpt should match") + assert.Equal(t, bookmark.Author, dto.Author, "Author should match") + assert.Equal(t, bookmark.Public, dto.Public, "Public should match") + assert.Equal(t, bookmark.CreatedAt, dto.CreatedAt, "CreatedAt should match") + assert.Equal(t, bookmark.ModifiedAt, dto.ModifiedAt, "ModifiedAt should match") + assert.Equal(t, bookmark.HasContent, dto.HasContent, "HasContent should match") + + // Verify default values for fields not in Bookmark + assert.Empty(t, dto.Content, "Content should be empty") + assert.Empty(t, dto.HTML, "HTML should be empty") + assert.Empty(t, dto.ImageURL, "ImageURL should be empty") + assert.Empty(t, dto.Tags, "Tags should be empty") + assert.False(t, dto.HasArchive, "HasArchive should be false") + assert.False(t, dto.HasEbook, "HasEbook should be false") + assert.False(t, dto.CreateArchive, "CreateArchive should be false") + assert.False(t, dto.CreateEbook, "CreateEbook should be false") +} + +func TestBookmarkDTOToBookmark(t *testing.T) { + // Create a test BookmarkDTO with all fields populated + dto := BookmarkDTO{ + ID: 123, + URL: "https://example.com", + Title: "Example Title", + Excerpt: "This is an excerpt", + Author: "John Doe", + Public: 1, + CreatedAt: "2023-01-01 12:00:00", + ModifiedAt: "2023-01-02 12:00:00", + Content: "This is the content", + HTML: "
This is HTML
", + ImageURL: "https://example.com/image.jpg", + HasContent: true, + Tags: []TagDTO{{Tag: Tag{ID: 1, Name: "tag1"}}, {Tag: Tag{ID: 2, Name: "tag2"}}}, + HasArchive: true, + HasEbook: true, + CreateArchive: true, + CreateEbook: true, + } + + // Convert to Bookmark + bookmark := dto.ToBookmark() + + // Verify all fields are correctly transferred + assert.Equal(t, dto.ID, bookmark.ID, "ID should match") + assert.Equal(t, dto.URL, bookmark.URL, "URL should match") + assert.Equal(t, dto.Title, bookmark.Title, "Title should match") + assert.Equal(t, dto.Excerpt, bookmark.Excerpt, "Excerpt should match") + assert.Equal(t, dto.Author, bookmark.Author, "Author should match") + assert.Equal(t, dto.Public, bookmark.Public, "Public should match") + assert.Equal(t, dto.CreatedAt, bookmark.CreatedAt, "CreatedAt should match") + assert.Equal(t, dto.ModifiedAt, bookmark.ModifiedAt, "ModifiedAt should match") + assert.Equal(t, dto.HasContent, bookmark.HasContent, "HasContent should match") + + // Fields that should not be transferred + // These fields are only in BookmarkDTO and not in Bookmark +} + +func TestGetThumbnailPath(t *testing.T) { + // Test cases + testCases := []struct { + name string + bookmark BookmarkDTO + expected string + }{ + { + name: "With ID", + bookmark: BookmarkDTO{ + ID: 123, + }, + expected: filepath.Join("thumb", "123"), + }, + { + name: "With zero ID", + bookmark: BookmarkDTO{ + ID: 0, + }, + expected: filepath.Join("thumb", "0"), + }, + } + + // Run test cases + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + path := GetThumbnailPath(&tc.bookmark) + assert.Equal(t, tc.expected, path, "Thumbnail path should match expected value") + }) + } +} + +func TestGetEbookPath(t *testing.T) { + // Test cases + testCases := []struct { + name string + bookmark BookmarkDTO + expected string + }{ + { + name: "With ID", + bookmark: BookmarkDTO{ + ID: 123, + }, + expected: filepath.Join("ebook", "123.epub"), + }, + { + name: "With zero ID", + bookmark: BookmarkDTO{ + ID: 0, + }, + expected: filepath.Join("ebook", "0.epub"), + }, + } + + // Run test cases + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + path := GetEbookPath(&tc.bookmark) + assert.Equal(t, tc.expected, path, "Ebook path should match expected value") + }) + } +} + +func TestGetArchivePath(t *testing.T) { + // Test cases + testCases := []struct { + name string + bookmark BookmarkDTO + expected string + }{ + { + name: "With ID", + bookmark: BookmarkDTO{ + ID: 123, + }, + expected: filepath.Join("archive", "123"), + }, + { + name: "With zero ID", + bookmark: BookmarkDTO{ + ID: 0, + }, + expected: filepath.Join("archive", "0"), + }, + } + + // Run test cases + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + path := GetArchivePath(&tc.bookmark) + assert.Equal(t, tc.expected, path, "Archive path should match expected value") + }) + } +} + +func TestBookmarkRoundTrip(t *testing.T) { + // Test that converting from Bookmark to DTO and back preserves data + original := Bookmark{ + ID: 123, + URL: "https://example.com", + Title: "Example Title", + Excerpt: "This is an excerpt", + Author: "John Doe", + Public: 1, + CreatedAt: "2023-01-01 12:00:00", + ModifiedAt: "2023-01-02 12:00:00", + HasContent: true, + } + + // Convert to DTO and back + dto := original.ToDTO() + roundTrip := dto.ToBookmark() + + // Verify all fields are preserved + assert.Equal(t, original.ID, roundTrip.ID, "ID should be preserved") + assert.Equal(t, original.URL, roundTrip.URL, "URL should be preserved") + assert.Equal(t, original.Title, roundTrip.Title, "Title should be preserved") + assert.Equal(t, original.Excerpt, roundTrip.Excerpt, "Excerpt should be preserved") + assert.Equal(t, original.Author, roundTrip.Author, "Author should be preserved") + assert.Equal(t, original.Public, roundTrip.Public, "Public should be preserved") + assert.Equal(t, original.CreatedAt, roundTrip.CreatedAt, "CreatedAt should be preserved") + assert.Equal(t, original.ModifiedAt, roundTrip.ModifiedAt, "ModifiedAt should be preserved") + assert.Equal(t, original.HasContent, roundTrip.HasContent, "HasContent should be preserved") +} diff --git a/internal/model/database.go b/internal/model/database.go index 4d3160048..fd27304a6 100644 --- a/internal/model/database.go +++ b/internal/model/database.go @@ -29,6 +29,10 @@ type DB interface { // SaveBookmarks saves bookmarks data to database. SaveBookmarks(ctx context.Context, create bool, bookmarks ...BookmarkDTO) ([]BookmarkDTO, error) + // SaveBookmark saves a single bookmark to database without handling tags. + // It only updates the bookmark data in the database. + SaveBookmark(ctx context.Context, bookmark Bookmark) error + // GetBookmarks fetch list of bookmarks based on submitted options. GetBookmarks(ctx context.Context, opts DBGetBookmarksOptions) ([]BookmarkDTO, error) @@ -57,13 +61,29 @@ type DB interface { DeleteAccount(ctx context.Context, id DBID) error // CreateTags creates new tags in database. - CreateTags(ctx context.Context, tags ...Tag) error + CreateTags(ctx context.Context, tags ...Tag) ([]Tag, error) + + // CreateTag creates a new tag in database. + CreateTag(ctx context.Context, tag Tag) (Tag, error) // GetTags fetch list of tags and its frequency from database. GetTags(ctx context.Context) ([]TagDTO, error) // RenameTag change the name of a tag. RenameTag(ctx context.Context, id int, newName string) error + + // GetTag fetch a tag by its ID. + GetTag(ctx context.Context, id int) (TagDTO, bool, error) + + // UpdateTag updates a tag in the database. + UpdateTag(ctx context.Context, tag Tag) error + + // DeleteTag removes a tag from the database. + DeleteTag(ctx context.Context, id int) error + + // BulkUpdateBookmarkTags updates tags for multiple bookmarks. + // It ensures that all bookmarks and tags exist before proceeding. + BulkUpdateBookmarkTags(ctx context.Context, bookmarkIDs []int, tagIDs []int) error } // DBOrderMethod is the order method for getting bookmarks @@ -101,3 +121,9 @@ type DBListAccountsOptions struct { // Retrieve password content WithPassword bool } + +// DBListTagsOptions is options for fetching tags from database. +type DBListTagsOptions struct { + BookmarkID int + WithBookmarkCount bool +} diff --git a/internal/model/domains.go b/internal/model/domains.go index ecd2c1551..9b0028514 100644 --- a/internal/model/domains.go +++ b/internal/model/domains.go @@ -17,6 +17,7 @@ type BookmarksDomain interface { GetBookmark(ctx context.Context, id DBID) (*BookmarkDTO, error) GetBookmarks(ctx context.Context, ids []int) ([]BookmarkDTO, error) UpdateBookmarkCache(ctx context.Context, bookmark BookmarkDTO, keepMetadata bool, skipExist bool) (*BookmarkDTO, error) + BulkUpdateBookmarkTags(ctx context.Context, bookmarkIDs []int, tagIDs []int) error } type AuthDomain interface { @@ -49,7 +50,7 @@ type StorageDomain interface { type TagsDomain interface { ListTags(ctx context.Context) ([]TagDTO, error) CreateTag(ctx context.Context, tag TagDTO) (TagDTO, error) - // GetTag(ctx context.Context, id int64) (TagDTO, error) - // UpdateTag(ctx context.Context, tag TagDTO) (TagDTO, error) - // DeleteTag(ctx context.Context, id int64) error + GetTag(ctx context.Context, id int) (TagDTO, error) + UpdateTag(ctx context.Context, tag TagDTO) (TagDTO, error) + DeleteTag(ctx context.Context, id int) error } diff --git a/internal/model/slices.go b/internal/model/slices.go new file mode 100644 index 000000000..0882ca740 --- /dev/null +++ b/internal/model/slices.go @@ -0,0 +1,21 @@ +package model + +// SliceDifference returns the elements that are in haystack but not in needle. +// It's a generic function that works with any comparable type. +func SliceDifference[T comparable](haystack, needle []T) []T { + // Create a map of needle elements for quick lookup + needleMap := make(map[T]bool) + for _, item := range needle { + needleMap[item] = true + } + + // Find elements in haystack that are not in needle + var difference []T + for _, item := range haystack { + if !needleMap[item] { + difference = append(difference, item) + } + } + + return difference +} diff --git a/internal/model/slices_test.go b/internal/model/slices_test.go new file mode 100644 index 000000000..0534d69ef --- /dev/null +++ b/internal/model/slices_test.go @@ -0,0 +1,49 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSliceDifference(t *testing.T) { + t.Run("empty_slices", func(t *testing.T) { + result := SliceDifference([]int{}, []int{}) + assert.Empty(t, result, "Difference of empty slices should be empty") + }) + + t.Run("empty_haystack", func(t *testing.T) { + result := SliceDifference([]int{}, []int{1, 2, 3}) + assert.Empty(t, result, "Difference with empty haystack should be empty") + }) + + t.Run("empty_needle", func(t *testing.T) { + result := SliceDifference([]int{1, 2, 3}, []int{}) + assert.Equal(t, []int{1, 2, 3}, result, "Difference with empty needle should be the haystack") + }) + + t.Run("no_difference", func(t *testing.T) { + result := SliceDifference([]int{1, 2, 3}, []int{1, 2, 3}) + assert.Empty(t, result, "Difference of identical slices should be empty") + }) + + t.Run("partial_difference", func(t *testing.T) { + result := SliceDifference([]int{1, 2, 3, 4}, []int{2, 4}) + assert.Equal(t, []int{1, 3}, result, "Should return elements in haystack but not in needle") + }) + + t.Run("complete_difference", func(t *testing.T) { + result := SliceDifference([]int{1, 2, 3}, []int{4, 5, 6}) + assert.Equal(t, []int{1, 2, 3}, result, "Should return all elements from haystack when needle has no common elements") + }) + + t.Run("with_duplicates", func(t *testing.T) { + result := SliceDifference([]int{1, 2, 2, 3, 3, 3}, []int{2, 3}) + assert.Equal(t, []int{1}, result, "Should handle duplicates correctly") + }) + + t.Run("string_type", func(t *testing.T) { + result := SliceDifference([]string{"a", "b", "c"}, []string{"b"}) + assert.Equal(t, []string{"a", "c"}, result, "Should work with string type") + }) +} diff --git a/internal/model/tag.go b/internal/model/tag.go index 08b47b895..76a9f19f6 100644 --- a/internal/model/tag.go +++ b/internal/model/tag.go @@ -1,5 +1,11 @@ package model +// BookmarkTag is the relationship between a bookmark and a tag. +type BookmarkTag struct { + BookmarkID int `db:"bookmark_id"` + TagID int `db:"tag_id"` +} + // Tag is the tag for a bookmark. type Tag struct { ID int `db:"id" json:"id"` diff --git a/internal/view/assets/js/page/home.js b/internal/view/assets/js/page/home.js index 7484ccb66..4f8cd6641 100644 --- a/internal/view/assets/js/page/home.js +++ b/internal/view/assets/js/page/home.js @@ -888,7 +888,7 @@ export default { }; this.dialog.loading = true; - fetch(new URL("api/bookmarks/tags", document.baseURI), { + fetch(new URL("api/v1/bookmarks/tags", document.baseURI), { method: "put", body: JSON.stringify(request), headers: { @@ -961,16 +961,10 @@ export default { ? `"#${data.newName}"` : `#${data.newName}`; - // Send data - var newData = { - id: tag.id, - name: data.newName, - }; - this.dialog.loading = true; - fetch(new URL("api/tags", document.baseURI), { + fetch(new URL("api/v1/tags/" + tag.id, document.baseURI), { method: "PUT", - body: JSON.stringify(newData), + body: JSON.stringify({ name: data.newName }), headers: { "Content-Type": "application/json", Authorization: "Bearer " + localStorage.getItem("shiori-token"), diff --git a/scripts/test.sh b/scripts/test.sh index 08b65a958..ef005f505 100644 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -8,7 +8,7 @@ fi # if gotestfmt is installed, run with it if [ -x "$(command -v gotestfmt)" ]; then set -o pipefail - go test ${SOURCE_FILES} ${GO_TEST_FLAGS} -json | gotestfmt ${GOTESTFMT_FLAGS} + go test ${SOURCE_FILES} -json ${GO_TEST_FLAGS} | gotestfmt ${GOTESTFMT_FLAGS} else go test ${SOURCE_FILES} ${GO_TEST_FLAGS} fi