diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 958a8ac..18bd4a3 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -9,6 +9,7 @@ RUN apt-get update && apt-get install -y \ ffmpeg \ sqlite3 \ rsync \ + less \ && rm -rf /var/lib/apt/lists/* \ # for cross compilation with raspberry pi && rustup target add aarch64-unknown-linux-gnu \ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index cae7130..a8e11e6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -17,8 +17,7 @@ folders indexed by a database and accessible via an api layer. # Hardware / OS designed primarily with linux in mind. Specifically a headless raspberry pi. -Currently runs flawlessly on a 3B. Windows _might_ work for some stuff, but -supporting it is not a priority +Currently runs flawlessly on a 3B. Windows support is not a priority. # General structure @@ -31,10 +30,10 @@ supporting it is not a priority - src/assets/* - contains non-rust assets used via `include_str!`; mainly sql files - src/assets/migration/* - database migration files -- src/assets/queries/* - general-purpose sqlite3 files +- src/assets/queries/* - general-purpose sqlite3 files, split out into + categories based on what the queries touch - src/assets/init.sql - database initialization -- src/model/* - dumping ground for all models. Needs to be split out. No - newly-created models should be put here (see the section `Organization` below) +- src/model/* - dumping ground for all models. Needs to be split out. - src/previews/* - all file preview functionality. Currently takes the first step outlined in the `Organization` section below - src/queue/* - all queueing functionality. Might need to be refactored later @@ -51,6 +50,43 @@ supporting it is not a priority is compared with the latest upgrade version and upgrades are applied accordingly. +not everything follows this pattern, however. Refer to the `Structure Migration` +section for any new changes + +## Structure Migration + +in an attempt to modularize the codebase and better organize it, All new changes +need to be organized like this: + +- src/<module_name>: the name of the general functionality + - handler.rs (optional): endpoint functions for use with rocket + - repository.rs: database layer interactions + - service.rs: main logic layer of the feature + - tests: tests for the feature + - handler.rs/repository.rs/service.rs: tests for the respective layer of + this feature + - <name>.rs: tests for any other file in the feature module folder + +each function should get its own `mod` in the respective test file. If more than +10 tests exist for the same function, it should be pulled out into its own test +file alongside the other test files for that module + +### Example + +``` +- src + - tags + - handler.rs + - mod.rs + - repository.rs + - service.rs + - tests + - handler.rs + - mod.rs + - repository.rs + - service.rs +``` + # Queue hardware strength is limited, and generating previews takes about ~1 second on @@ -59,17 +95,6 @@ hit for a user-defined amount of time (defaults to 30 seconds). `file_preview_consumer` is called in src/main.rs to set this up. All queue functionality is disabled during tests -# Organization - -the project was originally organized after general java+spring organization - -controller/handler layer, repository layer, service layer, etc. However that has -proven to not _really_ work with how I organize stuff. Going forward, I'd like -to use cargo workspaces to split out everything into its own crate (e.g. the -file handler, service layer, repository layer, and models all go in a specific -crate, same for folders, etc). Until then though, everything is in the same -crate and will be slowly migrated to models and _then_ crates to make the -migration easier - # Testing general test structure takes this format (pulled from code snippets in @@ -133,3 +158,14 @@ instead do this: let x = 1; format!("x: {x}"); ``` + +## On the `use` statement + +it's heavily preferred that `use` be declared at the top of the module. Rarely +should `use` be used in the top of a function. Under _**NO CIRCUMSTANCES**_ +should `use` be used in the middle of a function. + +# Sql files + +each sql file needs to be associated with a repository-layer function with the +same name diff --git a/.vscode/settings.json b/.vscode/settings.json index 5a3d675..c1fde32 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,8 @@ { - "commentTranslate.hover.enabled": true, "chat.agent.enabled": true, "chat.commandCenter.enabled": false, "chat.notifyWindowOnConfirmation": false, "telemetry.feedback.enabled": false, - "deno.enable": false + "deno.enable": false, + "sql-formatter.uppercase": false } diff --git a/Cargo.lock b/Cargo.lock index 8125420..5206de4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -918,7 +918,7 @@ dependencies = [ [[package]] name = "file_server" -version = "3.0.3" +version = "4.0.0" dependencies = [ "async-global-executor", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 0259192..6f16842 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "file_server" -version = "3.0.3" +version = "4.0.0" edition = "2024" authors = ["Ploiu"] diff --git a/README.md b/README.md index fce607a..0147e5e 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,8 @@ turn off this feature, set `RabbitMq.enabled` to `false` in `FileServer.toml` ## notes -generating file previews requires rabbitmq to be running _when this application starts_. Timing can vary depending on your device, but here's an example script +generating file previews requires rabbitmq to be running _when this application +starts_. Timing can vary depending on your device, but here's an example script that can guide you in booting up properly (works great in `/etc/rc.local`): ```shell @@ -83,4 +84,5 @@ sudo rabbitmq-server & sleep 45 sudo rabbitmqctl await_online_nodes 1 && $(./sudo file_server &) & ``` -you can also use `systemd` to ensure this launches after rabbit \ No newline at end of file + +you can also use `systemd` to ensure this launches after rabbit diff --git a/openapi.json b/openapi.json index 67822eb..d784e4c 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ }, "title": "Ploiu File Server", "description": "a self-hostable file server written in rust", - "version": "3.0.3" + "version": "4.0.0" }, "paths": { "/api/version": { @@ -1100,7 +1100,7 @@ "tags": { "type": "array", "items": { - "$ref": "#/components/schemas/tagApi" + "$ref": "#/components/schemas/taggedItemApi" } }, "size": { @@ -1130,13 +1130,6 @@ "type": "number", "nullable": true, "minimum": 0 - }, - "tags": { - "nullable": false, - "type": "array", - "items": { - "$ref": "#/components/schemas/tagApi" - } } } }, @@ -1218,12 +1211,13 @@ "tags": { "type": "array", "items": { - "$ref": "#/components/schemas/tagApi" + "$ref": "#/components/schemas/taggedItemApi" } } } }, "tagApi": { + "description": "an individual tag", "properties": { "id": { "nullable": true, @@ -1236,6 +1230,26 @@ } } }, + "taggedItemApi": { + "description": "a tag as it exists on a file or a folder", + "properties": { + "id": { + "nullable": true, + "type": "number", + "minimum": 0, + "description": "the id of the tag itself" + }, + "title": { + "type": "string", + "nullable": false + }, + "implicitFrom": { + "type": "number", + "nullable": true, + "description": "the folder that owns this tag and is implicating this tag. If null, the file / folder explicitly has this tag on itself. tags with an implicitFrom cannot be manually removed from the file / folder" + } + } + }, "createPassword": { "properties": { "username": { diff --git a/src/assets/migration/v6.sql b/src/assets/migration/v6.sql new file mode 100644 index 0000000..cde5ec6 --- /dev/null +++ b/src/assets/migration/v6.sql @@ -0,0 +1,216 @@ +-- create centralized tagged items table to simplify searching and tagging +begin; + +create table TaggedItems ( + id integer primary key, + tagId integer not null references Tags(id) on delete cascade, + fileId integer references FileRecords(id) on delete cascade, + folderId integer references Folders(id) on delete cascade, + -- items can only ever inherit tags from an ancestor folder. When this inherited folder is deleted, this tag should be removed too since it's no longer inherited + implicitFromId integer references Folders(id) on delete cascade default null, + -- make sure that either a file or a folder was tagged + check ((fileId is not null) != (folderId is not null)) +); + +-- partial unique to prevent the same tag from being applied to a tagged item +create unique index idx_tagged_items_unique_file on TaggedItems(tagId, fileId) +where + fileId is not null; + +create unique index idx_tagged_items_unique_folder on TaggedItems(tagId, folderId) +where + folderId is not null; + +-- migrate all direct tags for files +insert into + TaggedItems(tagId, fileId) +select + tagId, + fileRecordId +from + Files_Tags; + +-- migrate all direct tags for folders +insert into + TaggedItems(tagId, folderId) +select + tagId, + folderId +from + Folders_Tags; + +/* + populating inherited tags for folders (needs to be done first so that files work): + 1. recursively get all parent folders along with how far needed to be traveled for that parent folder (depth) + 2. get all tags for all parent folders + 3. for any duplicate tags, take only the ancestor id with the lowest depth (lower depth = higher specificity) + + if ai is helpful for anything, it's providing an example that I can adapt while I properly read how recursive sql queries work. + Previously, I was flailing around. It helps me if I think of it as a do while loop and temporary named queries / functions + */ +with recursive -- traverse the ancestor tree and track depth +ancestors(folderId, ancestorId, depth) as ( + -- base case: select all folders that have a parent + select + id as folderId, + parentId as ancestorId, + 1 as depth + from + folders + where + parentId is not null + union + all -- iteration: keep retrieving parents from base case until there are no more parents + select + a.folderId, + f.parentId as ancestorId, + a.depth + 1 + from + ancestors a + join folders f on f.id = a.ancestorId + where + f.parentId is not null +), +-- include all tags with fetched ancestors +ancestorTags as ( + select + a.folderId, + ft.tagId, + a.ancestorId, + a.depth + from + ancestors a + join folders_tags ft on ft.folderId = a.ancestorId +), +-- iterate through all retrieved ancestors. For each entry, find the tag on the ancestor with the lowest depth +nearestTags as ( + select + at.folderId, + at.tagId, + at.ancestorId + from + ancestorTags at + where + at.ancestorId = ( + -- compare on the current row and find the nearest ancestor + select + at2.ancestorId + from + ancestorTags at2 + where + at2.folderId = at.folderId + and at2.tagId = at.tagId + order by + at2.depth asc + limit + 1 + ) +) -- now that we have our functions, we can invoke nearestTags to get all the inherited tags and insert them +insert into + TaggedItems(tagId, folderId, implicitFromId) +select + n.tagId, + n.folderId, + n.ancestorId +from + nearestTags n -- important to not include tags that are directly on the folder +where + not exists ( + select + 1 + from + TaggedItems ti + where + ti.tagId = n.tagId + and ti.folderId = n.folderId + ); + +-- populate inherited tags for files: for each file, walk its containing folder(s)' ancestor chain +-- and pick the nearest ancestor that provides a tag, then insert an inherited row for the file +with recursive ancestors(fileId, directFolderId, ancestorId, depth) as ( + -- base: each file's direct containing folder is the first ancestor (so tags on the folder itself are inherited) + select + ff.fileId, + ff.folderId, + ff.folderId as ancestorId, + 1 as depth + from + Folder_Files ff + union + all -- climb up the folder parent chain + select + fa.fileId, + fa.directFolderId, + f.parentId as ancestorId, + fa.depth + 1 + from + ancestors fa + join Folders f on f.id = fa.ancestorId + where + f.parentId is not null +), +-- join the discovered ancestors to tags present on those ancestor folders +ancestorTags as ( + select + fa.fileId, + ft.tagId, + fa.ancestorId, + fa.depth + from + ancestors fa + join Folders_Tags ft on ft.folderId = fa.ancestorId +), +-- for each (file,tag) choose the nearest ancestor (smallest depth) +nearestTags as ( + select + cft.fileId, + cft.tagId, + cft.ancestorId + from + ancestorTags cft + where + cft.ancestorId = ( + select + cft2.ancestorId + from + ancestorTags cft2 + where + cft2.fileId = cft.fileId + and cft2.tagId = cft.tagId + order by + cft2.depth asc + limit + 1 + ) +) +insert into + TaggedItems(tagId, fileId, implicitFromId) +select + n.tagId, + n.fileId, + n.ancestorId +from + nearestTags n +where + not exists ( + select + 1 + from + TaggedItems ti + where + ti.tagId = n.tagId + and ti.fileId = n.fileId + ); + +drop table folders_tags; + +drop table files_tags; + +update + metadata +set + value = 6 +where + name = 'version'; + +commit; \ No newline at end of file diff --git a/src/assets/queries/file/get_all_ancestors.sql b/src/assets/queries/file/get_all_ancestors.sql new file mode 100644 index 0000000..3b26075 --- /dev/null +++ b/src/assets/queries/file/get_all_ancestors.sql @@ -0,0 +1,30 @@ +/* + travels up the ancestor tree of a file and retrieves the folder IDs. + The depth counter allows us to guarantee that retrieval order goes from closest to ?1 -> furthest from ?1 + */ +with recursive ancestors(id, depth) as ( + select + ff.folderId, + 1 + from + folder_files ff + where + ff.fileId = ?1 + and ff.folderId is not null + union + all + select + f.parentId, + a.depth + 1 + from + Folders f + join ancestors a on f.id = a.id + where + f.parentId is not null +) +select + id +from + ancestors +order by + depth asc; diff --git a/src/assets/queries/file/get_files_by_all_tags.sql b/src/assets/queries/file/get_files_by_all_tags.sql index 962e86d..59ae2ac 100644 --- a/src/assets/queries/file/get_files_by_all_tags.sql +++ b/src/assets/queries/file/get_files_by_all_tags.sql @@ -8,8 +8,8 @@ select group_concat(t.title) from FileRecords f - join Files_Tags ft on f.id = ft.fileRecordId - join Tags t on ft.tagId = t.id + join TaggedItems ti on f.id = ti.fileId + join Tags t on ti.tagId = t.id left join main.Folder_Files FF on f.id = FF.fileId where t.title in (?1) diff --git a/src/assets/queries/folder/get_ancestor_folders_with_id.sql b/src/assets/queries/folder/get_ancestor_folders_with_id.sql new file mode 100644 index 0000000..9083fd4 --- /dev/null +++ b/src/assets/queries/folder/get_ancestor_folders_with_id.sql @@ -0,0 +1,30 @@ +/* + travels up the ancestor tree of a folder and retrieves their IDs. + The depth counter allows us to guarantee that retrieval order goes from closest to ?1 -> furthest from ?1 + */ +with recursive ancestors(id, depth) as ( + select + parentId, + 1 + from + Folders + where + id = ?1 + and parentId is not null + union + all + select + f.parentId, + a.depth + 1 + from + Folders f + join ancestors a on f.id = a.id + where + f.parentId is not null +) +select + id +from + ancestors +order by + depth asc; \ No newline at end of file diff --git a/src/assets/queries/folder/get_folders_by_any_tag.sql b/src/assets/queries/folder/get_folders_by_any_tag.sql index 76aae6f..8900944 100644 --- a/src/assets/queries/folder/get_folders_by_any_tag.sql +++ b/src/assets/queries/folder/get_folders_by_any_tag.sql @@ -1,6 +1,13 @@ -select f.id, f.name, f.parentId, group_concat(t.title) -from folders f - join Folders_Tags ft on f.id = ft.folderId - join tags t on t.id = ft.tagId -where t.title in (?1) -group by f.id; +select + f.id, + f.name, + f.parentId, + group_concat(t.title) +from + folders f + join TaggedItems ti on ti.folderId = f.id + join tags t on t.id = ti.tagId +where + t.title in (?1) +group by + f.id; \ No newline at end of file diff --git a/src/assets/queries/folder/get_parent_folders_with_id.sql b/src/assets/queries/folder/get_parent_folders_with_id.sql deleted file mode 100644 index 7196e93..0000000 --- a/src/assets/queries/folder/get_parent_folders_with_id.sql +++ /dev/null @@ -1,10 +0,0 @@ -with recursive query(id) as ( - values(?1) - union - select parentId from Folders, query - where Folders.id = query.id -) -select distinct parentId from Folders -where Folders.parentId in query -and parentId <> ?1; - diff --git a/src/assets/queries/folder/get_parent_folders_with_tags.sql b/src/assets/queries/folder/get_parent_folders_with_tags.sql index 52c1f4d..2a92c51 100644 --- a/src/assets/queries/folder/get_parent_folders_with_tags.sql +++ b/src/assets/queries/folder/get_parent_folders_with_tags.sql @@ -1,14 +1,25 @@ -with recursive query(id) as (values (?1) - union - select parentId - from Folders, - query - where Folders.id = query.id) -select parentId, group_concat(t.title) -from Folders - left join folders_tags ft on ft.folderId = folders.parentId - left join tags t on t.id = ft.tagId -where Folders.parentId in query +with recursive query(id) as ( + values + (?1) + union + select + parentId + from + Folders, + query + where + Folders.id = query.id +) +select + parentId, + group_concat(t.title) +from + Folders + left join TaggedItems ti on ti.folderId = folders.parentId + left join tags t on t.id = ti.tagId +where + Folders.parentId in query and parentId <> ?1 and t.title in (?2) -group by parentId; +group by + parentId; \ No newline at end of file diff --git a/src/assets/queries/tags/add_implicit_tag_to_file.sql b/src/assets/queries/tags/add_implicit_tag_to_file.sql new file mode 100644 index 0000000..f36c3f1 --- /dev/null +++ b/src/assets/queries/tags/add_implicit_tag_to_file.sql @@ -0,0 +1,3 @@ +-- Add an implicit tag to a file (only if it doesn't already have it) +insert or ignore into TaggedItems(tagId, fileId, implicitFromId) +values (?1, ?2, ?3) diff --git a/src/assets/queries/tags/add_implicit_tag_to_folder.sql b/src/assets/queries/tags/add_implicit_tag_to_folder.sql new file mode 100644 index 0000000..2509ea7 --- /dev/null +++ b/src/assets/queries/tags/add_implicit_tag_to_folder.sql @@ -0,0 +1,3 @@ +-- Add an implicit tag to a folder (only if it doesn't already have it) +insert or ignore into TaggedItems(tagId, folderId, implicitFromId) +values (?1, ?2, ?3) diff --git a/src/assets/queries/tags/add_tag_to_file.sql b/src/assets/queries/tags/add_tag_to_file.sql index bdb4285..7b45246 100644 --- a/src/assets/queries/tags/add_tag_to_file.sql +++ b/src/assets/queries/tags/add_tag_to_file.sql @@ -1,2 +1,4 @@ -insert into Files_Tags(fileRecordId, tagId) -values(?1, ?2) +insert + or ignore into TaggedItems (fileId, tagId) +values + (?1, ?2) \ No newline at end of file diff --git a/src/assets/queries/tags/add_tag_to_folder.sql b/src/assets/queries/tags/add_tag_to_folder.sql index ae360c4..fe519b0 100644 --- a/src/assets/queries/tags/add_tag_to_folder.sql +++ b/src/assets/queries/tags/add_tag_to_folder.sql @@ -1,2 +1,4 @@ -insert into Folders_Tags(folderId, tagId) -values (?1, ?2) +insert + or ignore into TaggedItems (folderId, tagId) +values + (?1, ?2) \ No newline at end of file diff --git a/src/assets/queries/tags/get_all_tags_for_file.sql b/src/assets/queries/tags/get_all_tags_for_file.sql new file mode 100644 index 0000000..77a397a --- /dev/null +++ b/src/assets/queries/tags/get_all_tags_for_file.sql @@ -0,0 +1,12 @@ +select + ti.id, + ti.fileId, + ti.folderId, + ti.implicitFromId, + t.id, + t.title +from + Tags t + join TaggedItems ti on t.id = ti.tagId +where + ti.fileId = ?1 \ No newline at end of file diff --git a/src/assets/queries/tags/get_all_tags_for_files.sql b/src/assets/queries/tags/get_all_tags_for_files.sql new file mode 100644 index 0000000..40c1924 --- /dev/null +++ b/src/assets/queries/tags/get_all_tags_for_files.sql @@ -0,0 +1,12 @@ +select + ti.id, + ti.fileId, + ti.folderId, + ti.implicitFromId, + t.id, + t.title +from + Tags t + join TaggedItems ti on t.id = ti.tagId +where + ti.fileId in ({ }) \ No newline at end of file diff --git a/src/assets/queries/tags/get_all_tags_for_folder.sql b/src/assets/queries/tags/get_all_tags_for_folder.sql new file mode 100644 index 0000000..05f2bd8 --- /dev/null +++ b/src/assets/queries/tags/get_all_tags_for_folder.sql @@ -0,0 +1,12 @@ +select + ti.id, + ti.fileId, + ti.folderId, + ti.implicitFromId, + t.id, + t.title +from + Tags t + join TaggedItems ti on t.id = ti.tagId +where + ti.folderId = ?1 \ No newline at end of file diff --git a/src/assets/queries/tags/get_explicit_tags_for_file.sql b/src/assets/queries/tags/get_explicit_tags_for_file.sql new file mode 100644 index 0000000..bc8c4ec --- /dev/null +++ b/src/assets/queries/tags/get_explicit_tags_for_file.sql @@ -0,0 +1,13 @@ +select + ti.id, + ti.fileId, + ti.folderId, + ti.implicitFromId, + t.id, + t.title +from + Tags t + join TaggedItems ti on t.id = ti.tagId +where + ti.fileId = ?1 + and implicitFromId is null \ No newline at end of file diff --git a/src/assets/queries/tags/get_explicit_tags_for_folder.sql b/src/assets/queries/tags/get_explicit_tags_for_folder.sql new file mode 100644 index 0000000..57e389e --- /dev/null +++ b/src/assets/queries/tags/get_explicit_tags_for_folder.sql @@ -0,0 +1,13 @@ +select + ti.id, + ti.fileId, + ti.folderId, + ti.implicitFromId, + t.id, + t.title +from + Tags t + join TaggedItems ti on t.id = ti.tagId +where + ti.folderId = ?1 + and implicitFromId is null \ No newline at end of file diff --git a/src/assets/queries/tags/get_implicit_tags_for_file.sql b/src/assets/queries/tags/get_implicit_tags_for_file.sql new file mode 100644 index 0000000..7582b3e --- /dev/null +++ b/src/assets/queries/tags/get_implicit_tags_for_file.sql @@ -0,0 +1,13 @@ +select + ti.id, + ti.fileId, + ti.folderId, + ti.implicitFromId, + t.id, + t.title +from + Tags t + join TaggedItems ti on t.id = ti.tagId +where + ti.fileId = ?1 + and implicitFromId is not null \ No newline at end of file diff --git a/src/assets/queries/tags/get_implicit_tags_for_folder.sql b/src/assets/queries/tags/get_implicit_tags_for_folder.sql new file mode 100644 index 0000000..3a17d09 --- /dev/null +++ b/src/assets/queries/tags/get_implicit_tags_for_folder.sql @@ -0,0 +1,13 @@ +select + ti.id, + ti.fileId, + ti.folderId, + ti.implicitFromId, + t.id, + t.title +from + Tags t + join TaggedItems ti on t.id = ti.tagId +where + ti.folderId = ?1 + and implicitFromId is not null \ No newline at end of file diff --git a/src/assets/queries/tags/get_tags_for_file.sql b/src/assets/queries/tags/get_tags_for_file.sql deleted file mode 100644 index 3e88719..0000000 --- a/src/assets/queries/tags/get_tags_for_file.sql +++ /dev/null @@ -1,4 +0,0 @@ -select Tags.id, Tags.title -from Tags - join Files_Tags on Tags.id = Files_Tags.tagId -where Files_Tags.fileRecordId = ?1; diff --git a/src/assets/queries/tags/get_tags_for_files.sql b/src/assets/queries/tags/get_tags_for_files.sql deleted file mode 100644 index ab91303..0000000 --- a/src/assets/queries/tags/get_tags_for_files.sql +++ /dev/null @@ -1,4 +0,0 @@ -select Files_Tags.fileRecordId, Tags.id, Tags.title -from Tags - join Files_Tags on Tags.id = Files_Tags.tagId -where Files_Tags.fileRecordId in ({}); diff --git a/src/assets/queries/tags/get_tags_for_folder.sql b/src/assets/queries/tags/get_tags_for_folder.sql deleted file mode 100644 index f92eb70..0000000 --- a/src/assets/queries/tags/get_tags_for_folder.sql +++ /dev/null @@ -1,4 +0,0 @@ -select Tags.id, Tags.title -from Tags - join Folders_Tags on Tags.id = Folders_Tags.tagId -where Folders_Tags.folderId = ?1; diff --git a/src/assets/queries/tags/remove_explicit_tag_from_file.sql b/src/assets/queries/tags/remove_explicit_tag_from_file.sql new file mode 100644 index 0000000..805f5a5 --- /dev/null +++ b/src/assets/queries/tags/remove_explicit_tag_from_file.sql @@ -0,0 +1,7 @@ +-- removes a single non-inherited tag from a file +delete from + TaggedItems +where + fileId = ?1 + and tagId = ?2 + and implicitFromId is null; \ No newline at end of file diff --git a/src/assets/queries/tags/remove_explicit_tag_from_folder.sql b/src/assets/queries/tags/remove_explicit_tag_from_folder.sql new file mode 100644 index 0000000..764f775 --- /dev/null +++ b/src/assets/queries/tags/remove_explicit_tag_from_folder.sql @@ -0,0 +1,7 @@ +-- removes a single non-inherited tag from a folder +delete from + TaggedItems +where + folderId = ?1 + and tagId = ?2 + and implicitFromId is null; \ No newline at end of file diff --git a/src/assets/queries/tags/remove_implicit_tag_from_file.sql b/src/assets/queries/tags/remove_implicit_tag_from_file.sql new file mode 100644 index 0000000..b62baec --- /dev/null +++ b/src/assets/queries/tags/remove_implicit_tag_from_file.sql @@ -0,0 +1,4 @@ +delete from TaggedItems +where fileId = ?1 + and tagId = ?2 + and implicitFromId is not null diff --git a/src/assets/queries/tags/remove_stale_implicit_tags_from_descendants.sql b/src/assets/queries/tags/remove_stale_implicit_tags_from_descendants.sql new file mode 100644 index 0000000..3681006 --- /dev/null +++ b/src/assets/queries/tags/remove_stale_implicit_tags_from_descendants.sql @@ -0,0 +1,14 @@ +delete from + TaggedItems +where + implicitFromId = ?1 -- stupid formatter refuses to put comments above a line, so this stops it + -- this prevents removing tags that still should be implicated + and tagId not in ( + select + tagId + from + TaggedItems + where + folderId = ?1 + and implicitFromId is null + ) \ No newline at end of file diff --git a/src/assets/queries/tags/remove_tag_from_file.sql b/src/assets/queries/tags/remove_tag_from_file.sql deleted file mode 100644 index decfe04..0000000 --- a/src/assets/queries/tags/remove_tag_from_file.sql +++ /dev/null @@ -1,3 +0,0 @@ -delete from Files_Tags -where fileRecordId = ?1 -and tagId = ?2 diff --git a/src/assets/queries/tags/remove_tag_from_folder.sql b/src/assets/queries/tags/remove_tag_from_folder.sql deleted file mode 100644 index 0ba1b9e..0000000 --- a/src/assets/queries/tags/remove_tag_from_folder.sql +++ /dev/null @@ -1,3 +0,0 @@ -delete from Folders_Tags -where folderId = ?1 -and tagId = ?2 diff --git a/src/db_migrations.rs b/src/db_migrations.rs index 65ccb38..e1c3abd 100644 --- a/src/db_migrations.rs +++ b/src/db_migrations.rs @@ -112,6 +112,10 @@ pub fn migrate_db(con: &Connection, table_version: u64) -> Result<()> { log_migration_version(5); migrate_v5(con)?; } + if table_version < 6 { + log_migration_version(6); + migrate_v6(con)?; + } Ok(()) } @@ -138,3 +142,7 @@ fn migrate_v4(con: &Connection) -> Result<()> { fn migrate_v5(con: &Connection) -> Result<()> { con.execute_batch(include_str!("./assets/migration/v5.sql")) } + +fn migrate_v6(con: &Connection) -> Result<()> { + con.execute_batch(include_str!("./assets/migration/v6.sql")) +} diff --git a/src/exif/handler.rs b/src/exif/handler.rs index f1b59c9..31f60ea 100644 --- a/src/exif/handler.rs +++ b/src/exif/handler.rs @@ -3,19 +3,14 @@ use std::{ time::Instant, }; -use rocket::{http::Status, State}; +use rocket::{State, http::Status}; use crate::{ - guard::HeaderAuth, - model::guard::auth::ValidateResult, - util::update_last_request_time, + guard::HeaderAuth, model::guard::auth::ValidateResult, util::update_last_request_time, }; #[get("/regen")] -pub fn regenerate_exif( - auth: HeaderAuth, - last_request_time: &State>>, -) -> Status { +pub fn regenerate_exif(auth: HeaderAuth, last_request_time: &State>>) -> Status { match auth.validate() { ValidateResult::Ok => { /*no op*/ } ValidateResult::NoPasswordSet => return Status::Unauthorized, diff --git a/src/handler/api_handler.rs b/src/handler/api_handler.rs index 9c8b791..caddab6 100644 --- a/src/handler/api_handler.rs +++ b/src/handler/api_handler.rs @@ -16,7 +16,7 @@ use crate::model::response::api_responses::{ use crate::service::api_service::{self, DiskInfoError}; use crate::util::update_last_request_time; -static API_VERSION_NUMBER: &str = "3.0.3"; +static API_VERSION_NUMBER: &str = "4.0.0"; #[derive(Serialize)] #[serde(crate = "rocket::serde")] diff --git a/src/handler/file_handler.rs b/src/handler/file_handler.rs index 7e979c8..98b58d6 100644 --- a/src/handler/file_handler.rs +++ b/src/handler/file_handler.rs @@ -130,9 +130,6 @@ pub fn search_files( Err(SearchFileError::DbError) => SearchFileResponse::GenericError(BasicMessage::new( "Failed to search files. Check server logs for details", )), - Err(SearchFileError::TagError) => SearchFileResponse::GenericError(BasicMessage::new( - "Failed to retrieve file tags. Check server logs for details", - )), } } diff --git a/src/handler/mod.rs b/src/handler/mod.rs index 07f522e..21482b2 100644 --- a/src/handler/mod.rs +++ b/src/handler/mod.rs @@ -1,4 +1,3 @@ pub mod api_handler; pub mod file_handler; pub mod folder_handler; -pub mod tag_handler; diff --git a/src/main.rs b/src/main.rs index 78f8474..cb34173 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,8 @@ use std::{fs, time::Instant}; use rocket::{Build, Rocket}; use db_migrations::generate_all_file_types_and_sizes; -use handler::{api_handler::*, file_handler::*, folder_handler::*, tag_handler::*}; +use handler::{api_handler::*, file_handler::*, folder_handler::*}; +use tags::handler::*; use crate::exif::load_all_exif_data; use crate::handler::api_handler::update_password; @@ -29,6 +30,7 @@ mod previews; mod queue; mod repository; mod service; +mod tags; mod util; #[cfg(not(test))] diff --git a/src/model/api.rs b/src/model/api.rs index ebe3ec7..4d49703 100644 --- a/src/model/api.rs +++ b/src/model/api.rs @@ -4,15 +4,7 @@ use rocket::serde::{Deserialize, Serialize}; use crate::model::file_types::FileTypes; use crate::model::repository::FileRecord; -use crate::model::response::TagApi; - -#[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Hash, Clone)] -#[serde(crate = "rocket::serde")] -pub struct FileMetadata { - pub size: u32, - pub date_created: u64, - pub file_type: FileTypes, -} +use crate::model::response::TaggedItemApi; #[derive(Deserialize, Serialize, Debug, Hash, Clone, Eq)] #[cfg_attr(not(test), derive(PartialEq))] @@ -24,7 +16,7 @@ pub struct FileApi { pub folder_id: Option, /// this value may be unsafe, see [`FileApi::name`] pub name: String, - pub tags: Vec, + pub tags: Vec, // wrapped in option so api consumers don't have to send this field (these fields can't be written to after a file is uploaded) pub size: Option, #[serde(rename = "dateCreated", skip_serializing_if = "Option::is_none")] @@ -35,29 +27,23 @@ pub struct FileApi { impl FileApi { /// returns a sanitized string based on [Rocket's file name sanitization](https://api.rocket.rs/master/rocket/fs/struct.FileName.html#sanitization) - /// but with the exception of parentheses being replaced with `leftParenthese` and `rightParenthese` respectively. It's hacky, but parentheses in file - /// names are super common and don't immediately mean it's malicious /// will return None if the entire file name is unsafe pub fn name(&self) -> Option { //language=RegExp - let reserved_name_regex = Regex::new("^CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]$").unwrap(); + let reserved_name_regex = Regex::new("^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$").unwrap(); //language=RegExp let banned_chars = Regex::new("(^\\.\\.|^\\./)|[/\\\\<>|:&;#?*]").unwrap(); if reserved_name_regex.is_match(&self.name.to_uppercase()) - || self.name.starts_with("..") + || self.name.contains("..") || self.name.contains("./") { return None; } - let replaced = banned_chars.replace_all(&self.name, ""); - let replaced = replaced - .to_string() - .replace('(', "leftParenthese") - .replace(')', "rightParenthese"); + let replaced = banned_chars.replace_all(&self.name, "").to_string(); Some(replaced) } - pub fn from_with_tags(file: FileRecord, tags: Vec) -> Self { + pub fn from_with_tags(file: FileRecord, tags: Vec) -> Self { let mut api: Self = file.into(); api.tags = tags; api @@ -72,7 +58,6 @@ impl FileApi { tags: Vec::new(), size: None, date_created: None, - // TODO file_types file_type: None, } } @@ -124,15 +109,6 @@ mod update_file_request_tests { assert_eq!(".bashrc".to_string(), req.name().unwrap()); } - #[test] - fn name_replaces_parentheses() { - let req = FileApi::new(1, None, "test (1).txt".to_string()); - assert_eq!( - "test leftParenthese1rightParenthese.txt".to_string(), - req.name().unwrap() - ); - } - #[test] fn name_keeps_multiple_extensions() { let req = FileApi::new(1, None, "test.old.txt.bak".to_string()); diff --git a/src/model/error/file_errors.rs b/src/model/error/file_errors.rs index bbece3a..ee6d854 100644 --- a/src/model/error/file_errors.rs +++ b/src/model/error/file_errors.rs @@ -62,15 +62,12 @@ pub enum UpdateFileError { #[derive(PartialEq, Debug)] pub enum SearchFileError { DbError, - /// an issue occurred retrieving tags - TagError, } impl Display for SearchFileError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::DbError => write!(f, "SearchFileError::DbError"), - Self::TagError => write!(f, "SearchFileError::TagError"), } } } diff --git a/src/model/repository/mod.rs b/src/model/repository/mod.rs index 82b7f70..523d3dd 100644 --- a/src/model/repository/mod.rs +++ b/src/model/repository/mod.rs @@ -32,14 +32,6 @@ pub struct Folder { pub parent_id: Option, } -#[derive(Debug, PartialEq, Clone)] -pub struct Tag { - /// the id of the tag - pub id: u32, - /// the display name of the tag - pub title: String, -} - impl From<&FileApi> for FileRecord { fn from(value: &FileApi) -> Self { let create_date = value diff --git a/src/model/request/folder_requests.rs b/src/model/request/folder_requests.rs index 46362ee..bd607c8 100644 --- a/src/model/request/folder_requests.rs +++ b/src/model/request/folder_requests.rs @@ -1,6 +1,6 @@ use rocket::serde::{Deserialize, Serialize}; -use crate::model::response::TagApi; +use crate::model::response::TaggedItemApi; #[derive(Deserialize, Serialize)] #[serde(crate = "rocket::serde")] @@ -10,6 +10,9 @@ pub struct CreateFolderRequest { pub parent_id: Option, } +/// Intentional narrowing of [`crate::model::response::folder_responses::FolderResponse`] +/// +/// This narrowing allows us to safely handle requests to update a folder without worry of accidentally changing fields that shouldn't be changed #[derive(Deserialize, Serialize)] #[serde(crate = "rocket::serde")] pub struct UpdateFolderRequest { @@ -17,5 +20,5 @@ pub struct UpdateFolderRequest { pub name: String, #[serde(rename = "parentId")] pub parent_id: Option, - pub tags: Vec, + pub tags: Vec, } diff --git a/src/model/response/folder_responses.rs b/src/model/response/folder_responses.rs index 5db6b45..3f28dcc 100644 --- a/src/model/response/folder_responses.rs +++ b/src/model/response/folder_responses.rs @@ -6,7 +6,7 @@ use rocket::serde::{Deserialize, Serialize, json::Json}; use crate::model::api::FileApi; use crate::model::repository::Folder; -use crate::model::response::{BasicMessage, TagApi}; +use crate::model::response::{BasicMessage, TaggedItemApi}; type NoContent = (); @@ -20,11 +20,11 @@ pub struct FolderResponse { pub name: String, pub folders: Vec, pub files: Vec, - pub tags: Vec, + pub tags: Vec, } -impl AddAssign> for FolderResponse { - fn add_assign(&mut self, rhs: Vec) { +impl AddAssign> for FolderResponse { + fn add_assign(&mut self, rhs: Vec) { self.tags = rhs; } } diff --git a/src/model/response/mod.rs b/src/model/response/mod.rs index c30a50a..2dd0eb7 100644 --- a/src/model/response/mod.rs +++ b/src/model/response/mod.rs @@ -1,7 +1,7 @@ use rocket::serde::json::Json; use rocket::serde::{Deserialize, Serialize}; -use crate::model::repository; +use crate::tags::{Tag, TaggedItem}; pub mod api_responses; pub mod file_responses; @@ -25,6 +25,23 @@ pub struct TagApi { pub title: String, } +/// represents a tag _on_ a file or folder, not just a standalone tag. +/// +/// In order to maintain compatibility with existing clients, the [`id`] field matches the id of the [`Tag`], not the [`TaggedItem`]. +/// Since this will be on a file or a folder, that should be enough information to determine which record to modify or remove if needed +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash, Clone)] +#[serde(crate = "rocket::serde")] +pub struct TaggedItemApi { + /// the id of the tag itself, not the TaggedItemApi. Will be `None` if it's a new tag for that item coming from a client + #[serde(rename = "id")] + pub tag_id: Option, + /// the title of the tag + pub title: String, + /// the folder this tag is implicated by. Will be None if the tag is explicit + #[serde(rename = "implicitFrom")] + pub implicit_from: Option, +} + // ---------------------------------- impl BasicMessage { @@ -49,11 +66,21 @@ impl From for BasicMessage { } } -impl From for TagApi { - fn from(value: repository::Tag) -> Self { +impl From for TagApi { + fn from(value: Tag) -> Self { TagApi { id: Some(value.id), title: value.title, } } } + +impl From for TaggedItemApi { + fn from(value: TaggedItem) -> Self { + Self { + tag_id: Some(value.tag_id), + title: value.title, + implicit_from: value.implicit_from_id, + } + } +} diff --git a/src/previews/service.rs b/src/previews/service.rs index 5d2412a..e63ab66 100644 --- a/src/previews/service.rs +++ b/src/previews/service.rs @@ -243,7 +243,7 @@ pub fn get_previews_for_folder( } else { vec![folder_id] }; - let folder_res = folder_repository::get_child_files(repo_folder_id, &con); + let folder_res = folder_repository::get_child_files(&repo_folder_id, &con); con.close().unwrap(); let files = match folder_res { Ok(f) => f, diff --git a/src/repository/file_repository.rs b/src/repository/file_repository.rs index 05dbf21..55c4205 100644 --- a/src/repository/file_repository.rs +++ b/src/repository/file_repository.rs @@ -128,6 +128,7 @@ pub fn search_files_by_tags( .to_string() .replace("?1", joined_tags.as_str()) .replace("?2", tags.len().to_string().as_str()); + log::debug!("built sql: {replaced_string}"); let mut pst = con.prepare(replaced_string.as_str())?; let res = pst.query_map([], map_file_all_fields)?; res.into_iter().collect() @@ -161,6 +162,34 @@ pub fn get_all_file_ids(con: &Connection) -> Result, rusqlite::Error> { res.into_iter().collect() } +/// Retrieves all ids of the ancestor folders of the file with the passed `file_id`. +/// +/// Ancestor id order is guaranteed to be in order of closest parent to the file first. +/// For example, if a file is in folder D in A/B/C/D, it will return [D, C, B, A] +/// +/// ## Parameters: +/// - `file_id`: the id of the file whose ancestors need to be retrieved +/// - `con`: a connection to the database. Must be closed by the caller +/// +/// ## Returns: +/// - An empty vec if `file_id` is 0 +/// - An empty vec if the file does not exist +/// - The ancestor folder IDs in depth-first order (closest first) +pub fn get_all_ancestors(file_id: u32, con: &Connection) -> Result, rusqlite::Error> { + // Return empty vec for file_id = 0 as per spec + if file_id == 0 { + return Ok(Vec::new()); + } + + let mut pst = con.prepare(include_str!("../assets/queries/file/get_all_ancestors.sql"))?; + let mut ids: Vec = Vec::with_capacity(5); + let mut retrieved = pst.query([file_id])?; + while let Some(row) = retrieved.next()? { + ids.push(row.get(0)?); + } + Ok(ids) +} + pub fn map_file_all_fields(row: &rusqlite::Row) -> Result { let id = row.get(0)?; let name = row.get(1)?; @@ -793,3 +822,78 @@ mod convert_aliased_file_size_to_where_clause { assert_eq!(expected, actual); } } + +#[cfg(test)] +mod get_all_ancestors_tests { + use super::*; + use crate::repository::open_connection; + use crate::test::{cleanup, create_file_db_entry, create_folder_db_entry, init_db_folder}; + + #[test] + fn should_return_empty_vec_if_zero_passed() { + init_db_folder(); + let con = open_connection(); + let res = get_all_ancestors(0, &con).unwrap(); + con.close().unwrap(); + assert!(res.is_empty()); + cleanup(); + } + + #[test] + fn should_return_empty_vec_if_file_does_not_exist() { + init_db_folder(); + let con = open_connection(); + let res = get_all_ancestors(999, &con).unwrap(); + con.close().unwrap(); + assert!(res.is_empty()); + cleanup(); + } + + #[test] + fn should_return_ancestors_in_depth_first_order() { + init_db_folder(); + // Create folder hierarchy: A -> B -> C -> D + create_folder_db_entry("A", None); // id 1 + create_folder_db_entry("B", Some(1)); // id 2 + create_folder_db_entry("C", Some(2)); // id 3 + create_folder_db_entry("D", Some(3)); // id 4 + // Create file in folder D + create_file_db_entry("test.txt", Some(4)); // id 1 + + let con = open_connection(); + let res = get_all_ancestors(1, &con).unwrap(); + con.close().unwrap(); + + // Should return [D, C, B, A] = [4, 3, 2, 1] + assert_eq!(vec![4, 3, 2, 1], res); + cleanup(); + } + + #[test] + fn should_return_empty_vec_if_file_in_root() { + init_db_folder(); + // Create file in root (no parent folder) + create_file_db_entry("test.txt", None); // id 1 + + let con = open_connection(); + let res = get_all_ancestors(1, &con).unwrap(); + con.close().unwrap(); + + assert!(res.is_empty()); + cleanup(); + } + + #[test] + fn should_handle_single_parent() { + init_db_folder(); + create_folder_db_entry("A", None); // id 1 + create_file_db_entry("test.txt", Some(1)); // id 1 + + let con = open_connection(); + let res = get_all_ancestors(1, &con).unwrap(); + con.close().unwrap(); + + assert_eq!(vec![1], res); + cleanup(); + } +} diff --git a/src/repository/folder_repository.rs b/src/repository/folder_repository.rs index 4b4f358..979e391 100644 --- a/src/repository/folder_repository.rs +++ b/src/repository/folder_repository.rs @@ -1,5 +1,5 @@ use std::backtrace::Backtrace; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use rusqlite::{Connection, Rows, params}; @@ -128,12 +128,12 @@ pub fn update_folder(folder: &repository::Folder, con: &Connection) -> Result<() /// // get files in folders 1 and 2 /// let files = get_child_files([1u32, 2u32], &con)?; /// ``` -pub fn get_child_files>( - ids: T, +pub fn get_child_files( + ids: &[u32], con: &Connection, ) -> Result, rusqlite::Error> { // `is_empty` is not part of a trait, so we have to convert ids - let ids: HashSet = ids.into_iter().collect(); + let ids: HashSet = ids.iter().copied().collect(); if ids.is_empty() { get_child_files_root(con) } else { @@ -182,8 +182,8 @@ pub fn link_folder_to_file( } /// returns all the ids of all child folders recursively for the passed input_ids -pub fn get_all_child_folder_ids + Clone>( - input_ids: &T, +pub fn get_all_child_folder_ids( + input_ids: &[u32], con: &Connection, ) -> Result, rusqlite::Error> { let mut pst = con @@ -191,7 +191,7 @@ pub fn get_all_child_folder_ids + Clone>( "../assets/queries/folder/get_child_folder_ids_recursive.sql" )) .unwrap(); - let input_ids: HashSet = input_ids.clone().into_iter().collect(); + let input_ids: HashSet = input_ids.iter().copied().collect(); let mut ids: Vec = Vec::new(); let joined_ids = if input_ids.is_empty() { String::new() @@ -209,69 +209,26 @@ pub fn get_all_child_folder_ids + Clone>( Ok(ids) } -pub fn get_folders_by_any_tag( - tags: &HashSet, - con: &Connection, -) -> Result, rusqlite::Error> { - // TODO look at rarray to pass a collection as a parameter (https://docs.rs/rusqlite/0.29.0/rusqlite/vtab/array/index.html) - let joined_tags = tags - .iter() - .map(|t| format!("'{}'", t.replace('\'', "''"))) - .reduce(|combined, current| format!("{combined},{current}")) - .unwrap(); - let query = include_str!("../assets/queries/folder/get_folders_by_any_tag.sql"); - let replaced_query = query.replace("?1", joined_tags.as_str()); - let mut pst = con.prepare(replaced_query.as_str()).unwrap(); - let mut folders: HashSet = HashSet::new(); - let rows = pst.query_map([], map_folder)?; - for row in rows { - folders.insert(row?); - } - Ok(folders) -} - -pub fn get_parent_folders_by_tag<'a, T: IntoIterator + Clone>( - folder_id: u32, - tags: &T, - con: &Connection, -) -> Result>, rusqlite::Error> { - let query = include_str!("../assets/queries/folder/get_parent_folders_with_tags.sql"); - // because I'm not using a rusqlite extension, I have to join the list of tags manually - let joined_tags = tags - .clone() - .into_iter() - .map(|t| format!("'{}'", t.replace('\'', "''"))) - .reduce(|combined, current| format!("{combined},{current}")) - .unwrap(); - let built_query = query.replace("?2", joined_tags.as_str()); - let mut pst = con.prepare(built_query.as_str())?; - let mut pairs: HashMap> = HashMap::new(); - let mut rows = pst.query([folder_id])?; - while let Some(row) = rows.next()? { - let folder_id: u32 = row.get(0)?; - let tags: String = row.get(1)?; - let split_tags = tags - .split(',') - .map(|s| s.to_string()) - .collect::>(); - pairs.insert(folder_id, split_tags); - } - Ok(pairs) -} - -/// returns a recursive list of ancestor (parent/grandparent/great grandparent/etc) folder IDs for the passed `folder_id` -/// This does not include the root folder id of `None`/`0` -pub fn get_ancestor_folder_ids( +/// Retrieves all ids of the ancestor folders of the folder with the passed `folder_id`. +/// +/// Ancestor id order is guaranteed to be in order of closest parent to the folder first. +/// For example, if called in folder D in A/B/C/D/E, it will return [C, B, A] +/// +/// ## Parameters: +/// - `folder_id`: the id of the folder whose ancestors need to be retrieved +/// - `con`: a connection to the database. Must be closed by the caller +pub fn get_ancestor_folders_with_id( folder_id: u32, con: &Connection, ) -> Result, rusqlite::Error> { let mut pst = con.prepare(include_str!( - "../assets/queries/folder/get_parent_folders_with_id.sql" + "../assets/queries/folder/get_ancestor_folders_with_id.sql" ))?; - let mut rows = pst.query([folder_id])?; - let mut ids: Vec = Vec::new(); - while let Some(row) = rows.next()? { - ids.push(row.get(0)?); + // while it's possible for a folder to be nested more than 5 layers deep, 5 is a good starting tradeoff for most folders (at least for my use case) + let mut ids: Vec = Vec::with_capacity(5); + let mut retrieved = pst.query([folder_id])?; + while let Some(id) = retrieved.next()? { + ids.push(id.get(0)?); } Ok(ids) } @@ -320,76 +277,6 @@ fn get_child_files_non_root( Ok(files) } -#[cfg(test)] -mod get_folders_by_any_tag_tests { - use std::collections::HashSet; - - use rusqlite::Connection; - - use crate::model::repository::Folder; - use crate::repository::folder_repository::get_folders_by_any_tag; - use crate::repository::open_connection; - use crate::test::{cleanup, create_folder_db_entry, create_tag_folders, init_db_folder}; - - #[test] - fn returns_folders_with_any_tag() { - init_db_folder(); - create_folder_db_entry("all tags", None); // 1 - create_folder_db_entry("some tags", Some(1)); // 2 - create_folder_db_entry("no tags", None); // 3 - create_folder_db_entry("no relevant tags", None); // 4 - // tags on them folders - create_tag_folders("irrelevant", vec![2, 4]); - create_tag_folders("relevant 1", vec![1, 2]); - create_tag_folders("relevant 2", vec![1]); - let con: Connection = open_connection(); - - let res = get_folders_by_any_tag( - &HashSet::from(["relevant 1".to_string(), "relevant 2".to_string()]), - &con, - ) - .unwrap() - .into_iter() - .collect::>(); - con.close().unwrap(); - assert_eq!(2, res.len()); - assert!(res.contains(&Folder { - id: Some(1), - parent_id: None, - name: "all tags".to_string(), - })); - assert!(res.contains(&Folder { - id: Some(2), - parent_id: Some(1), - name: "some tags".to_string(), - })); - cleanup(); - } -} - -#[cfg(test)] -mod get_parent_folders_by_tag_tests { - use std::collections::HashSet; - - use crate::repository::folder_repository::get_parent_folders_by_tag; - use crate::repository::open_connection; - use crate::test::{cleanup, create_folder_db_entry, create_tag_folder, init_db_folder}; - - #[test] - fn retrieves_parent_folders() { - init_db_folder(); - create_folder_db_entry("top", None); - create_folder_db_entry("middle", Some(1)); - create_folder_db_entry("bottom", Some(2)); - create_tag_folder("tag", 1); - let con = open_connection(); - let res = get_parent_folders_by_tag(3, &[&"tag".to_string()], &con).unwrap(); - con.close().unwrap(); - assert_eq!(HashSet::from(["tag".to_string()]), *res.get(&1).unwrap()); - cleanup(); - } -} - #[cfg(test)] mod get_child_files_tests { use std::collections::HashSet; @@ -406,7 +293,7 @@ mod get_child_files_tests { create_folder_db_entry("top", None); create_file_db_entry("bad", Some(1)); let con = open_connection(); - let res: HashSet = get_child_files([], &con) + let res: HashSet = get_child_files(&[], &con) .unwrap() .into_iter() .map(|f| f.name) @@ -428,7 +315,7 @@ mod get_child_files_tests { create_file_db_entry("good", Some(1)); create_file_db_entry("good2", Some(2)); let con = open_connection(); - let res: HashSet = get_child_files([1, 2], &con) + let res: HashSet = get_child_files(&[1, 2], &con) .unwrap() .into_iter() .map(|f| f.name) @@ -443,52 +330,43 @@ mod get_child_files_tests { } #[cfg(test)] -mod get_ancestor_folder_ids_tests { - use super::get_ancestor_folder_ids; - use crate::{ - repository::open_connection, - test::{cleanup, create_folder_db_entry, init_db_folder}, - }; +mod get_ancestor_folders_with_id { + use crate::repository::folder_repository::get_ancestor_folders_with_id; + use crate::repository::open_connection; + use crate::test::{cleanup, create_folder_db_entry, init_db_folder}; #[test] - fn returns_all_parents() { + fn should_return_empty_vec_if_no_parents() { init_db_folder(); - create_folder_db_entry("1", None); - create_folder_db_entry("2", Some(1)); - create_folder_db_entry("3", Some(2)); - create_folder_db_entry("4", Some(3)); - create_folder_db_entry("5", Some(4)); - let expected = vec![1, 2, 3, 4]; + create_folder_db_entry("top", None); let con = open_connection(); - let actual = get_ancestor_folder_ids(5, &con).unwrap(); + let res = get_ancestor_folders_with_id(1, &con).unwrap(); con.close().unwrap(); - assert_eq!(actual, expected); + assert!(res.is_empty()); cleanup(); } #[test] - fn does_not_return_non_parents() { + fn should_return_empty_vec_if_folder_does_not_exist() { init_db_folder(); - create_folder_db_entry("good", None); // 1 - create_folder_db_entry("good", Some(1)); // 2 - create_folder_db_entry("bad", Some(1)); // 3 - create_folder_db_entry("good", Some(2)); // 4 - create_folder_db_entry("base", Some(4)); // 5 let con = open_connection(); - let expected = vec![1, 2, 4]; - let actual = get_ancestor_folder_ids(5, &con).unwrap(); - assert_eq!(actual, expected); + let res = get_ancestor_folders_with_id(999, &con).unwrap(); + con.close().unwrap(); + assert!(res.is_empty()); cleanup(); } #[test] - fn does_not_panic_when_no_parents() { + fn should_return_ancestors_in_depth_first_order() { init_db_folder(); + create_folder_db_entry("A", None); + create_folder_db_entry("B", Some(1)); + create_folder_db_entry("C", Some(2)); + create_folder_db_entry("D", Some(3)); let con = open_connection(); - create_folder_db_entry("test", None); - let res = get_ancestor_folder_ids(1, &con); + let res = get_ancestor_folders_with_id(4, &con).unwrap(); con.close().unwrap(); - res.expect("no error should be returned if the folder does not have a parent"); + assert_eq!(vec![3, 2, 1], res); cleanup(); } } diff --git a/src/repository/mod.rs b/src/repository/mod.rs index aec2afc..c9e52e7 100644 --- a/src/repository/mod.rs +++ b/src/repository/mod.rs @@ -9,7 +9,6 @@ use crate::db_migrations::migrate_db; pub mod file_repository; pub mod folder_repository; pub mod metadata_repository; -pub mod tag_repository; /// creates a new connection and returns it, but panics if the connection could not be created #[cfg(not(test))] diff --git a/src/repository/tag_repository.rs b/src/repository/tag_repository.rs deleted file mode 100644 index 4a0a179..0000000 --- a/src/repository/tag_repository.rs +++ /dev/null @@ -1,541 +0,0 @@ -use std::{backtrace::Backtrace, collections::HashMap}; - -use rusqlite::Connection; - -use crate::model::repository; - -/// creates a new tag in the database. This does not check if the tag already exists, -/// so the caller must check that themselves -pub fn create_tag(title: &str, con: &Connection) -> Result { - let mut pst = con.prepare(include_str!("../assets/queries/tags/create_tag.sql"))?; - let id = pst.insert(rusqlite::params![title])? as u32; - Ok(repository::Tag { - id, - title: title.to_string(), - }) -} - -/// searches for a tag that case-insensitively matches that passed title. -/// -/// if `None` is returned, that means there was no match -pub fn get_tag_by_title( - title: &str, - con: &Connection, -) -> Result, rusqlite::Error> { - let mut pst = con.prepare(include_str!("../assets/queries/tags/get_by_title.sql"))?; - match pst.query_row(rusqlite::params![title], tag_mapper) { - Ok(tag) => Ok(Some(tag)), - Err(e) => { - // no tag found - if e == rusqlite::Error::QueryReturnedNoRows { - Ok(None) - } else { - log::error!( - "Failed to get tag by name, error is {e:?}\n{}", - Backtrace::force_capture() - ); - Err(e) - } - } - } -} - -pub fn get_tag(id: u32, con: &Connection) -> Result { - let mut pst = con.prepare(include_str!("../assets/queries/tags/get_by_id.sql"))?; - pst.query_row(rusqlite::params![id], tag_mapper) -} - -/// updates the past tag. Checking to make sure the tag exists needs to be done on the caller's end -pub fn update_tag(tag: repository::Tag, con: &Connection) -> Result<(), rusqlite::Error> { - let mut pst = con.prepare(include_str!("../assets/queries/tags/update_tag.sql"))?; - pst.execute(rusqlite::params![tag.title, tag.id])?; - Ok(()) -} - -pub fn delete_tag(id: u32, con: &Connection) -> Result<(), rusqlite::Error> { - let mut pst = con.prepare(include_str!("../assets/queries/tags/delete_tag.sql"))?; - pst.execute(rusqlite::params![id])?; - Ok(()) -} - -/// the caller of this function will need to make sure the tag already exists and isn't already on the file -pub fn add_tag_to_file(file_id: u32, tag_id: u32, con: &Connection) -> Result<(), rusqlite::Error> { - let mut pst = con.prepare(include_str!("../assets/queries/tags/add_tag_to_file.sql"))?; - pst.execute(rusqlite::params![file_id, tag_id])?; - Ok(()) -} - -pub fn get_tags_on_file( - file_id: u32, - con: &Connection, -) -> Result, rusqlite::Error> { - let mut pst = con.prepare(include_str!("../assets/queries/tags/get_tags_for_file.sql"))?; - let rows = pst.query_map(rusqlite::params![file_id], tag_mapper)?; - let mut tags: Vec = Vec::new(); - for tag_res in rows { - // I know it's probably bad style, but I'm laughing too hard at the double question mark. - // no I don't know what my code is doing and I'm glad my code reflects that - tags.push(tag_res?); - } - Ok(tags) -} - -pub fn get_tags_on_files( - file_ids: Vec, - con: &Connection, -) -> Result>, rusqlite::Error> { - struct TagFile { - file_id: u32, - tag_id: u32, - tag_title: String, - } - let in_clause: Vec = file_ids.iter().map(|it| format!("'{it}'")).collect(); - let in_clause = in_clause.join(","); - let formatted_query = format!( - include_str!("../assets/queries/tags/get_tags_for_files.sql"), - in_clause - ); - let mut pst = con.prepare(formatted_query.as_str())?; - let rows = pst.query_map([], |row| { - let file_id: u32 = row.get(0)?; - let tag_id: u32 = row.get(1)?; - let tag_title: String = row.get(2)?; - Ok(TagFile { - file_id, - tag_id, - tag_title, - }) - })?; - let mut mapped: HashMap> = HashMap::new(); - for res in rows { - let res = res?; - if let std::collections::hash_map::Entry::Vacant(e) = mapped.entry(res.file_id) { - e.insert(vec![repository::Tag { - id: res.tag_id, - title: res.tag_title, - }]); - } else { - mapped.get_mut(&res.file_id).unwrap().push(repository::Tag { - id: res.tag_id, - title: res.tag_title, - }); - } - } - Ok(mapped) -} - -pub fn remove_tag_from_file( - file_id: u32, - tag_id: u32, - con: &Connection, -) -> Result<(), rusqlite::Error> { - let mut pst = con.prepare(include_str!( - "../assets/queries/tags/remove_tag_from_file.sql" - ))?; - pst.execute(rusqlite::params![file_id, tag_id])?; - Ok(()) -} - -pub fn add_tag_to_folder( - folder_id: u32, - tag_id: u32, - con: &Connection, -) -> Result<(), rusqlite::Error> { - let mut pst = con.prepare(include_str!("../assets/queries/tags/add_tag_to_folder.sql"))?; - pst.execute(rusqlite::params![folder_id, tag_id])?; - Ok(()) -} - -pub fn get_tags_on_folder( - folder_id: u32, - con: &Connection, -) -> Result, rusqlite::Error> { - let mut pst = con.prepare(include_str!( - "../assets/queries/tags/get_tags_for_folder.sql" - ))?; - let rows = pst.query_map(rusqlite::params![folder_id], |row| Ok(tag_mapper(row)))?; - let mut tags: Vec = Vec::new(); - for tag_res in rows { - // I know it's probably bad style, but I'm laughing too hard at the double question mark. - // no I don't know what my code is doing and I'm glad my code reflects that - tags.push(tag_res??); - } - Ok(tags) -} - -pub fn remove_tag_from_folder( - folder_id: u32, - tag_id: u32, - con: &Connection, -) -> Result<(), rusqlite::Error> { - let mut pst = con.prepare(include_str!( - "../assets/queries/tags/remove_tag_from_folder.sql" - ))?; - pst.execute(rusqlite::params![folder_id, tag_id])?; - Ok(()) -} - -fn tag_mapper(row: &rusqlite::Row) -> Result { - let id: u32 = row.get(0)?; - let title: String = row.get(1)?; - Ok(repository::Tag { id, title }) -} - -#[cfg(test)] -mod create_tag_tests { - use crate::model::repository::Tag; - use crate::repository::{open_connection, tag_repository}; - use crate::test::{cleanup, init_db_folder}; - - #[test] - fn create_tag() { - init_db_folder(); - let con = open_connection(); - let tag = tag_repository::create_tag("test", &con).unwrap(); - con.close().unwrap(); - assert_eq!( - Tag { - id: 1, - title: "test".to_string(), - }, - tag - ); - cleanup(); - } -} - -#[cfg(test)] -mod get_tag_by_title_tests { - use crate::model::repository::Tag; - use crate::repository::open_connection; - use crate::repository::tag_repository::{create_tag, get_tag_by_title}; - use crate::test::*; - - #[test] - fn get_tag_by_title_found() { - init_db_folder(); - let con = open_connection(); - create_tag("test", &con).unwrap(); - let found = get_tag_by_title("TeSt", &con).unwrap(); - con.close().unwrap(); - assert_eq!( - Some(Tag { - id: 1, - title: "test".to_string(), - }), - found - ); - cleanup(); - } - #[test] - fn get_tag_by_title_not_found() { - init_db_folder(); - let con = open_connection(); - let not_found = get_tag_by_title("test", &con).unwrap(); - con.close().unwrap(); - assert_eq!(None, not_found); - cleanup(); - } -} - -#[cfg(test)] -mod get_tag_by_id_tests { - use crate::model::repository::Tag; - use crate::repository::open_connection; - use crate::repository::tag_repository::{create_tag, get_tag}; - use crate::test::{cleanup, init_db_folder}; - - #[test] - fn get_tag_success() { - init_db_folder(); - let con = open_connection(); - create_tag("test", &con).unwrap(); - let tag = get_tag(1, &con).unwrap(); - con.close().unwrap(); - assert_eq!( - Tag { - id: 1, - title: "test".to_string(), - }, - tag - ); - cleanup(); - } -} - -#[cfg(test)] -mod update_tag_tests { - use crate::model::repository::Tag; - use crate::repository::open_connection; - use crate::repository::tag_repository::{create_tag, get_tag, update_tag}; - use crate::test::{cleanup, init_db_folder}; - - #[test] - fn update_tag_success() { - init_db_folder(); - let con = open_connection(); - create_tag("test", &con).unwrap(); - update_tag( - Tag { - id: 1, - title: "test2".to_string(), - }, - &con, - ) - .unwrap(); - let res = get_tag(1, &con).unwrap(); - con.close().unwrap(); - assert_eq!( - Tag { - id: 1, - title: "test2".to_string(), - }, - res - ); - cleanup(); - } -} - -#[cfg(test)] -mod delete_tag_tests { - use crate::repository::open_connection; - use crate::repository::tag_repository::{create_tag, delete_tag, get_tag}; - use crate::test::{cleanup, init_db_folder}; - - #[test] - fn delete_tag_success() { - init_db_folder(); - let con = open_connection(); - create_tag("test", &con).unwrap(); - delete_tag(1, &con).unwrap(); - let not_found = get_tag(1, &con); - con.close().unwrap(); - assert_eq!(Err(rusqlite::Error::QueryReturnedNoRows), not_found); - cleanup(); - } -} - -#[cfg(test)] -mod get_tag_on_file_tests { - use super::*; - use crate::model::file_types::FileTypes; - use crate::model::repository::{FileRecord, Tag}; - use crate::repository::file_repository::create_file; - use crate::repository::open_connection; - use crate::test::*; - - #[test] - fn get_tags_on_file_returns_tags() { - init_db_folder(); - let con = open_connection(); - create_tag("test", &con).unwrap(); - create_tag("test2", &con).unwrap(); - create_file( - &FileRecord { - id: None, - name: "test_file".to_string(), - parent_id: None, - create_date: now(), - size: 0, - file_type: FileTypes::Unknown, - }, - &con, - ) - .unwrap(); - add_tag_to_file(1, 1, &con).unwrap(); - add_tag_to_file(1, 2, &con).unwrap(); - let res = get_tags_on_file(1, &con).unwrap(); - con.close().unwrap(); - assert_eq!( - vec![ - Tag { - id: 1, - title: "test".to_string() - }, - Tag { - id: 2, - title: "test2".to_string() - } - ], - res - ); - cleanup(); - } - #[test] - fn get_tags_on_file_returns_nothing_if_no_tags() { - init_db_folder(); - let con = open_connection(); - create_file( - &FileRecord { - id: None, - name: "test_file".to_string(), - parent_id: None, - create_date: now(), - size: 0, - file_type: FileTypes::Application, - }, - &con, - ) - .unwrap(); - let res = get_tags_on_file(1, &con).unwrap(); - con.close().unwrap(); - assert_eq!(Vec::::new(), res); - cleanup(); - } -} - -#[cfg(test)] -mod remove_tag_from_file_tests { - use super::*; - use crate::model::file_types::FileTypes; - use crate::model::repository::{FileRecord, Tag}; - use crate::repository::file_repository::create_file; - use crate::repository::open_connection; - use crate::test::{cleanup, init_db_folder, now}; - - #[test] - fn remove_tag_from_file_works() { - init_db_folder(); - let con = open_connection(); - create_tag("test", &con).unwrap(); - create_file( - &FileRecord { - id: None, - name: "test_file".to_string(), - parent_id: None, - create_date: now(), - size: 0, - file_type: FileTypes::Unknown, - }, - &con, - ) - .unwrap(); - remove_tag_from_file(1, 1, &con).unwrap(); - let tags = get_tags_on_file(1, &con).unwrap(); - con.close().unwrap(); - assert_eq!(Vec::::new(), tags); - cleanup(); - } -} - -#[cfg(test)] -mod get_tag_on_folder_tests { - use crate::model::repository::{Folder, Tag}; - use crate::repository::folder_repository::create_folder; - use crate::repository::open_connection; - use crate::repository::tag_repository::{add_tag_to_folder, create_tag, get_tags_on_folder}; - use crate::test::*; - - #[test] - fn get_tags_on_folder_returns_tags() { - init_db_folder(); - let con = open_connection(); - create_tag("test", &con).unwrap(); - create_tag("test2", &con).unwrap(); - create_folder( - &Folder { - parent_id: None, - id: None, - name: "test_folder".to_string(), - }, - &con, - ) - .unwrap(); - add_tag_to_folder(1, 1, &con).unwrap(); - add_tag_to_folder(1, 2, &con).unwrap(); - let res = get_tags_on_folder(1, &con).unwrap(); - con.close().unwrap(); - assert_eq!( - vec![ - Tag { - id: 1, - title: "test".to_string() - }, - Tag { - id: 2, - title: "test2".to_string() - } - ], - res - ); - cleanup(); - } - #[test] - fn get_tags_on_folder_returns_nothing_if_no_tags() { - init_db_folder(); - let con = open_connection(); - create_folder( - &Folder { - parent_id: None, - id: None, - name: "test_folder".to_string(), - }, - &con, - ) - .unwrap(); - let res = get_tags_on_folder(1, &con).unwrap(); - con.close().unwrap(); - assert_eq!(Vec::::new(), res); - cleanup(); - } -} - -#[cfg(test)] -mod remove_tag_from_folder_tests { - use crate::model::repository::{Folder, Tag}; - use crate::repository::folder_repository::create_folder; - use crate::repository::open_connection; - use crate::repository::tag_repository::{ - create_tag, get_tags_on_folder, remove_tag_from_folder, - }; - use crate::test::{cleanup, init_db_folder}; - - #[test] - fn remove_tag_from_folder_works() { - init_db_folder(); - let con = open_connection(); - create_tag("test", &con).unwrap(); - create_folder( - &Folder { - parent_id: None, - id: None, - name: "test_folder".to_string(), - }, - &con, - ) - .unwrap(); - remove_tag_from_folder(1, 1, &con).unwrap(); - let tags = get_tags_on_folder(1, &con).unwrap(); - con.close().unwrap(); - assert_eq!(Vec::::new(), tags); - cleanup(); - } -} - -#[cfg(test)] -mod get_tags_on_files_tests { - use std::collections::HashMap; - - use crate::{model::repository::Tag, repository::open_connection, test::*}; - - #[test] - fn returns_proper_mapping_for_file_tags() { - init_db_folder(); - create_file_db_entry("file1", None); - create_file_db_entry("file2", None); - create_file_db_entry("control", None); - create_tag_file("tag1", 1); - create_tag_file("tag2", 1); - create_tag_file("tag3", 2); - let con = open_connection(); - let res = super::get_tags_on_files(vec![1, 2, 3], &con).unwrap(); - con.close().unwrap(); - #[rustfmt::skip] - let expected = HashMap::from([ - (1, vec![Tag {id: 1, title: "tag1".to_string()}, Tag {id: 2, title: "tag2".to_string()}]), - (2, vec![Tag {id: 3, title: "tag3".to_string()}]) - ]); - assert_eq!(res, expected); - cleanup(); - } -} diff --git a/src/service/file_service.rs b/src/service/file_service.rs index 5fa765b..2d2c15e 100644 --- a/src/service/file_service.rs +++ b/src/service/file_service.rs @@ -20,10 +20,13 @@ use crate::model::error::folder_errors::{GetFolderError, LinkFolderError}; use crate::model::file_types::FileTypes; use crate::model::repository::FileRecord; use crate::model::request::file_requests::CreateFileRequest; +use crate::model::response::TaggedItemApi; use crate::model::response::folder_responses::FolderResponse; use crate::previews; use crate::repository::{file_repository, folder_repository, open_connection}; -use crate::service::{folder_service, tag_service}; +use crate::service::folder_service; +use crate::tags::repository as tag_repository; +use crate::tags::service as tag_service; use crate::{queue, repository}; /// mapping of file lowercase file extension => file type @@ -237,6 +240,7 @@ pub async fn save_file( file_id = created.id.unwrap(); created.into() }; + tag_service::update_file_tags(file_id, vec![]).map_err(|_| CreateFileError::FailWriteDb)?; // now publish the file to the rabbit queue so a preview can be generated for it later queue::publish_message("icon_gen", &file_id.to_string()); Ok(resulting_file) @@ -337,28 +341,73 @@ pub fn delete_file_by_id_with_connection(id: u32, con: &Connection) -> Result<() Ok(()) } +/// Removes all implicit tags from a file that were implied by its old ancestor folders. +/// +/// This function should be called when a file is moved to a new parent folder, +/// before the file's parent is updated in the database. +/// +/// ## Parameters +/// - `file_id`: the ID of the file whose old ancestor tags should be removed +/// - `con`: a database connection. Must be closed by the caller +/// +/// ## Returns +/// - `Ok(())` if tags were successfully removed +/// - `Err(UpdateFileError::TagError)` if there was an error removing tags +fn remove_all_stale_ancestor_tags(file_id: u32, con: &Connection) -> Result<(), UpdateFileError> { + // Get all ancestors of the file at its current location + let old_ancestor_ids = match file_repository::get_all_ancestors(file_id, con) { + Ok(ids) => ids, + Err(e) => { + log::error!( + "Failed to get ancestors for file {file_id}! Nested exception is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(UpdateFileError::TagError); + } + }; + + // Remove all implicit tags from old ancestors + // Parameters: file_ids, folder_ids (empty because we only operate on files), implicit_from_ids + if let Err(e) = + tag_repository::batch_remove_implicit_tags(&[file_id], &[], &old_ancestor_ids, con) + { + log::error!( + "Failed to remove implicit tags from file {file_id}! Nested exception is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(UpdateFileError::TagError); + } + + Ok(()) +} + pub fn update_file(file: FileApi) -> Result { + log::debug!("file being updated: {file:?}"); let mut file = file; // first check if the file exists - let con: Connection = repository::open_connection(); + let con = repository::open_connection(); let repo_file = file_repository::get_file(file.id, &con); - if repo_file.is_err() { + let repo_file = if let Ok(f) = repo_file { + f + } else { con.close().unwrap(); return Err(UpdateFileError::NotFound); - } - let repo_file = repo_file.unwrap(); - // now check if the folder exists - let parent_folder = - folder_service::get_folder(file.folder_id).map_err(|_| UpdateFileError::FolderNotFound)?; + }; + let new_parent_folder = if let Ok(f) = folder_service::get_folder(file.folder_id) { + f + } else { + con.close().unwrap(); + return Err(UpdateFileError::FolderNotFound); + }; // now check if a file with the passed name is already under that folder let name_regex = Regex::new(format!("^{}$", file.name().unwrap()).as_str()).unwrap(); - for f in parent_folder.files.iter() { + for f in new_parent_folder.files.iter() { // make sure to ignore name collision if the file with the same name is the exact same file if f.id != file.id && name_regex.is_match(f.name.as_str()) { return Err(UpdateFileError::FileAlreadyExists); } } - for f in parent_folder.folders.iter() { + for f in new_parent_folder.folders.iter() { if name_regex.is_match(f.name.as_str()) { return Err(UpdateFileError::FolderAlreadyExistsWithSameName); } @@ -369,12 +418,15 @@ pub fn update_file(file: FileApi) -> Result { file_dir(), file_repository::get_file_path(file.id, &con).unwrap() ); - // now that we've verified that the file & folder exist and we're not gonna collide names, perform the move - let new_parent_id = if file.folder_id == Some(0) { - None - } else { - file.folder_id - }; + + // Check if the parent folder has changed and remove old ancestor tags if so + let new_parent_id = file.folder_id.filter(|&it| it != 0); + let old_parent_id = repo_file.parent_id; + if new_parent_id != old_parent_id { + remove_all_stale_ancestor_tags(file.id, &con)?; + } + + // Update the file in the database with new parent // ensure file type gets updated if the name is changed file.file_type = Some(determine_file_type(&file.name)); let converted_record = FileRecord::from(&file); @@ -386,15 +438,22 @@ pub fn update_file(file: FileApi) -> Result { ); return Err(UpdateFileError::DbError); } - // now that we've updated the file in the database, it's time to update the file system + // now that we've updated the file in the database, it's time to update the file system. + // sanitization is handled in file.name() let new_path = format!( "{}/{}/{}", file_dir(), - parent_folder.path, + new_parent_folder.path, file.name().unwrap() ); // update the file's tags in the db - if tag_service::update_file_tags(file.id, file.tags.clone()).is_err() { + let explicit_tags: Vec = file + .tags + .iter() + .filter(|it| it.implicit_from.is_none()) + .cloned() + .collect(); + if tag_service::update_file_tags(file.id, explicit_tags).is_err() { con.close().unwrap(); return Err(UpdateFileError::TagError); } @@ -626,7 +685,7 @@ fn check_file_in_dir( } else { vec![file_input.folder_id()] }; - let child_files = folder_repository::get_child_files(db_parent_id, &con); + let child_files = folder_repository::get_child_files(&db_parent_id, &con); con.close().unwrap(); if child_files.is_err() { return Err(CreateFileError::FailWriteDb); @@ -686,13 +745,16 @@ mod update_file_tests { use crate::model::error::file_errors::UpdateFileError; use crate::model::file_types::FileTypes; - use crate::model::response::TagApi; + use crate::model::response::TaggedItemApi; use crate::model::response::folder_responses::FolderResponse; + use crate::repository::open_connection; use crate::service::file_service::{file_dir, get_file_metadata, update_file}; use crate::service::folder_service; + use crate::tags::repository as tag_repository; use crate::test::{ cleanup, create_file_db_entry, create_file_disk, create_folder_db_entry, - create_folder_disk, create_tag_file, init_db_folder, now, + create_folder_disk, create_tag_db_entry, create_tag_file, create_tag_folder, + imply_tag_on_file, init_db_folder, now, }; #[test] @@ -704,9 +766,10 @@ mod update_file_tests { id: 1, folder_id: Some(0), name: "test.txt".to_string(), - tags: vec![TagApi { - id: None, + tags: vec![TaggedItemApi { + tag_id: None, title: "tag1".to_string(), + implicit_from: None, }], size: Some(0), date_created: Some(now()), @@ -719,9 +782,10 @@ mod update_file_tests { assert_eq!(res.folder_id, None); assert_eq!( res.tags, - vec![TagApi { - id: Some(1), + vec![TaggedItemApi { + tag_id: Some(1), title: "tag1".to_string(), + implicit_from: None, }] ); assert_eq!(res.file_type, Some(FileTypes::Text)); @@ -1027,6 +1091,230 @@ mod update_file_tests { assert_eq!(Some(FileTypes::Text), retrieved.unwrap().file_type); cleanup(); } + + #[test] + fn file_moved_loses_all_implied_tags_from_old_ancestors() { + init_db_folder(); + // Create folder hierarchy: A -> B and C + create_folder_db_entry("A", None); // id 1 + create_folder_db_entry("B", Some(1)); // id 2 + create_folder_db_entry("C", None); // id 3 + create_folder_disk("A"); + create_folder_disk("A/B"); + create_folder_disk("C"); + + // Add tags to A and B + create_tag_folder("tagA", 1); + create_tag_folder("tagB", 2); + + // Create file in B (should inherit tagA and tagB) + create_file_db_entry("test.txt", Some(2)); // id 1 + create_file_disk("A/B/test.txt", "test"); + + // Manually imply tags first (simulating initial state) + imply_tag_on_file(1, 1, 1); // tagA from A + imply_tag_on_file(2, 1, 2); // tagB from B + + // Move file to C (different branch) + update_file(FileApi { + id: 1, + name: "test.txt".to_string(), + folder_id: Some(3), + tags: vec![], + size: Some(0), + date_created: Some(now()), + file_type: None, + }) + .unwrap(); + + // File should have no tags now (all old implied tags removed) + let tags = get_file_metadata(1).unwrap().tags; + assert_eq!(tags.len(), 0); + + cleanup(); + } + + #[test] + fn file_moved_keeps_all_explicit_tags() { + init_db_folder(); + // Create folder hierarchy: A -> B and C + create_folder_db_entry("A", None); // id 1 + create_folder_db_entry("B", Some(1)); // id 2 + create_folder_db_entry("C", None); // id 3 + create_folder_disk("A"); + create_folder_disk("A/B"); + create_folder_disk("C"); + + // Create file in B with explicit tag + create_file_db_entry("test.txt", Some(2)); // id 1 + create_file_disk("A/B/test.txt", "test"); + create_tag_file("explicitTag", 1); + + // Move file to C + update_file(FileApi { + id: 1, + name: "test.txt".to_string(), + folder_id: Some(3), + tags: vec![TaggedItemApi { + tag_id: Some(1), + title: "explicitTag".to_string(), + implicit_from: None, + }], + size: Some(0), + date_created: Some(now()), + file_type: None, + }) + .unwrap(); + + // File should still have explicit tag + let tags = get_file_metadata(1).unwrap().tags; + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].title, "explicitTag"); + assert_eq!(tags[0].implicit_from, None); + + cleanup(); + } + + #[test] + fn file_moved_new_ancestors_do_not_override_explicit_tags() { + init_db_folder(); + // Create folders + create_folder_db_entry("A", None); // id 1 + create_folder_db_entry("B", None); // id 2 + create_folder_disk("A"); + create_folder_disk("B"); + + // Create a tag that will be on both the folder and the file + let tag_id = create_tag_db_entry("sharedTag"); + + // Add tag to folder B and file in A + let con = open_connection(); + tag_repository::add_explicit_tag_to_folder(2, tag_id, &con).unwrap(); + + // Create file in A with explicit sharedTag + create_file_db_entry("test.txt", Some(1)); // id 1 + create_file_disk("A/test.txt", "test"); + tag_repository::add_explicit_tag_to_file(1, tag_id, &con).unwrap(); + con.close().unwrap(); + + // Move file to B (which also has sharedTag) + update_file(FileApi { + id: 1, + name: "test.txt".to_string(), + folder_id: Some(2), + tags: vec![TaggedItemApi { + tag_id: Some(tag_id), + title: "sharedTag".to_string(), + implicit_from: None, + }], + size: Some(0), + date_created: Some(now()), + file_type: None, + }) + .unwrap(); + + // File should still have explicit tag (not overridden by implicit) + let tags = get_file_metadata(1).unwrap().tags; + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].title, "sharedTag"); + assert_eq!(tags[0].implicit_from, None); // Still explicit + + cleanup(); + } + + #[test] + fn file_moved_implicates_all_explicit_tags_of_new_ancestors() { + init_db_folder(); + // Create folder hierarchy: A -> B and C + create_folder_db_entry("A", None); // id 1 + create_folder_db_entry("B", Some(1)); // id 2 + create_folder_db_entry("C", None); // id 3 + create_folder_disk("A"); + create_folder_disk("A/B"); + create_folder_disk("C"); + + // Add tags to C + create_tag_folder("tagC", 3); + + // Create file in A/B + create_file_db_entry("test.txt", Some(2)); // id 1 + create_file_disk("A/B/test.txt", "test"); + + // Move file to C + update_file(FileApi { + id: 1, + name: "test.txt".to_string(), + folder_id: Some(3), + tags: vec![], + size: Some(0), + date_created: Some(now()), + file_type: None, + }) + .unwrap(); + + // File should have tagC implied from C + let tags = get_file_metadata(1).unwrap().tags; + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].title, "tagC"); + assert_eq!(tags[0].implicit_from, Some(3)); + + cleanup(); + } + + #[test] + fn update_file_only_updates_explicit_tags() { + init_db_folder(); + // Create folder hierarchy + create_folder_db_entry("A", None); // id 1 + create_folder_disk("A"); + + // Add tag to folder A + create_tag_folder("implicitTag", 1); + + // Create file in A with explicit tag and implicit tag from A + create_file_db_entry("test.txt", Some(1)); // id 1 + create_file_disk("A/test.txt", "test"); + create_tag_file("explicitTag", 1); // id 2, explicit + imply_tag_on_file(1, 1, 1); // implicitTag from folder A + + // Verify file has both explicit and implicit tags before update + let file_before = get_file_metadata(1).unwrap(); + assert_eq!(file_before.tags.len(), 2); + + // Update file with both explicit and implicit tags in the request + let updated = update_file(FileApi { + id: 1, + name: "test.txt".to_string(), + // simulate file moved, will make sure that the file doesn't keep the implied tag as its own + folder_id: None, + tags: vec![ + TaggedItemApi { + tag_id: Some(2), + title: "explicitTag".to_string(), + implicit_from: None, + }, + TaggedItemApi { + tag_id: Some(1), + title: "implicitTag".to_string(), + implicit_from: Some(1), + }, + ], + size: Some(0), + date_created: Some(now()), + file_type: None, + }) + .unwrap(); + + // After update, file should only have the original explicit tag + // The explicit tag should remain explicit + assert_eq!(updated.tags.len(), 1); + + let explicit_tag = updated.tags.first().unwrap(); + assert_eq!(&explicit_tag.title, "explicitTag"); + assert_eq!(explicit_tag.implicit_from, None); + + cleanup(); + } } #[cfg(test)] diff --git a/src/service/folder_service.rs b/src/service/folder_service.rs index 3d00d03..99d8db4 100644 --- a/src/service/folder_service.rs +++ b/src/service/folder_service.rs @@ -1,9 +1,9 @@ use std::backtrace::Backtrace; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::fs::{self, File}; -use std::hash::Hash; use std::path::Path; +use itertools::Itertools; use regex::Regex; use rusqlite::Connection; @@ -16,14 +16,15 @@ use crate::model::error::folder_errors::{ UpdateFolderError, }; -use crate::model::repository::Tag; use crate::model::request::folder_requests::{CreateFolderRequest, UpdateFolderRequest}; -use crate::model::response::TagApi; +use crate::model::response::TaggedItemApi; use crate::model::response::folder_responses::FolderResponse; use crate::previews; -use crate::repository::{folder_repository, open_connection, tag_repository}; +use crate::repository::{folder_repository, open_connection}; +use crate::service::file_service; use crate::service::file_service::{check_root_dir, file_dir}; -use crate::service::{file_service, tag_service}; +use crate::tags::repository as tag_repository; +use crate::tags::service as tag_service; use crate::{model, repository}; pub fn get_folder(id: Option) -> Result { @@ -48,9 +49,9 @@ pub fn get_folder(id: Option) -> Result { }; let mut converted_folders: Vec = Vec::new(); for child in child_folders { - let tags: Vec = - match tag_repository::get_tags_on_folder(child.id.unwrap_or(0), &con) { - Ok(t) => t.into_iter().map(|it| it.into()).collect(), + let tags: Vec = + match tag_repository::get_all_tags_for_folder(child.id.unwrap_or(0), &con) { + Ok(t) => t.into_iter().map_into().collect(), Err(e) => { log::error!( "Failed to retrieve tags for folder. Exception is {e:?}\n{}", @@ -98,6 +99,8 @@ pub async fn create_folder( Ok(f) => { let folder_path = format!("{}/{}", file_dir(), f.name); let fs_path = Path::new(folder_path.as_str()); + tag_service::update_folder_tags(f.id.unwrap(), vec![]) + .map_err(|_| CreateFolderError::DbFailure)?; match fs::create_dir(fs_path) { Ok(_) => Ok(f.into()), Err(_) => Err(CreateFolderError::FileSystemFailure), @@ -111,6 +114,7 @@ pub fn update_folder(folder: &UpdateFolderRequest) -> Result f, Err(GetFolderError::NotFound) => return Err(UpdateFolderError::NotFound), @@ -121,9 +125,33 @@ pub fn update_folder(folder: &UpdateFolderRequest) -> Result a, + Err(e) => { + log::error!( + "Failed to retrieve ancestor folders for folder {}. Error is {e:?}\n{}", + folder.id, + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(UpdateFolderError::DbFailure); + } + }; + con.close().unwrap(); + ancestors + } else { + Vec::new() + }; + let updated_folder = update_folder_internal(&db_folder)?; // if we can't rename the folder, then we have problems if let Err(e) = fs::rename( @@ -144,12 +172,21 @@ pub fn update_folder(folder: &UpdateFolderRequest) -> Result { /*no op*/ } - Err(_) => { - return Err(UpdateFolderError::TagError); - } - }; + + // If the parent changed, remove implicit tags from descendants that came from old ancestors + if parent_id_changed { + handle_folder_move_for_tags(folder.id, original_ancestors)?; + } + + // Filter out implicit tags - only update explicit tags + let explicit_tags: Vec = folder + .tags + .iter() + .filter(|it| it.implicit_from.is_none()) + .cloned() + .collect(); + tag_service::update_folder_tags(updated_folder.id.unwrap(), explicit_tags) + .map_err(|_| UpdateFolderError::TagError)?; Ok(FolderResponse { id: updated_folder.id.unwrap(), folders: Vec::new(), @@ -166,11 +203,9 @@ pub fn update_folder(folder: &UpdateFolderRequest) -> Result) -> bool { let con: Connection = open_connection(); - let db_id = if Some(0) == id || id.is_none() { - None - } else { - id - }; + // no id in the database if it's 0. 0 || None -> None, else keep original value of id + // TODO change to is_none_or once it's no longer unstable (https://doc.rust-lang.org/stable/std/option/enum.Option.html#method.is_none_or) + let db_id = id.filter(|&it| it != 0); let res = folder_repository::get_by_id(db_id, &con); con.close().unwrap(); res.is_ok() @@ -196,116 +231,13 @@ pub fn delete_folder(id: u32) -> Result<(), DeleteFolderError> { Ok(()) } -pub fn get_folders_by_any_tag( - tags: &HashSet, -) -> Result, GetFolderError> { - let con: Connection = open_connection(); - let folders = match folder_repository::get_folders_by_any_tag(tags, &con) { - Ok(f) => f, - Err(e) => { - con.close().unwrap(); - log::error!( - "Failed to pull folders by any tag. Exception is {e}\n{}", - Backtrace::force_capture() - ); - return Err(GetFolderError::DbFailure); - } - }; - con.close().unwrap(); - let mut converted_folders: HashSet = HashSet::with_capacity(folders.len()); - for folder in folders { - let tags = match tag_service::get_tags_on_folder(folder.id.unwrap()) { - Ok(t) => t, - Err(_) => return Err(GetFolderError::TagError), - }; - converted_folders.insert(FolderResponse { - id: folder.id.unwrap(), - parent_id: folder.parent_id, - name: folder.name, - path: "no path".to_string(), - folders: Vec::new(), - files: Vec::new(), - tags, - }); - } - Ok(converted_folders) -} - -/// will reduce a list of folders down to the first one that has all the tags -/// the folders passed must be all the folders retrieved in [folder_service::get_folders_by_any_tag] -pub fn reduce_folders_by_tag( - folders: &HashSet, - tags: &HashSet, -) -> Result, GetFolderError> { - // an index of the contents of condensed, to easily look up entries. - let mut condensed_list: HashMap = HashMap::new(); - // this will never change, because sometimes we need to pull folder info no longer in the condensed list if we're a child - let mut input_index: HashMap = HashMap::new(); - for folder in folders { - // I don't like having to clone all the folders, but with just references the compiler complains about reference lifetimes - condensed_list.insert(folder.id, folder.clone()); - input_index.insert(folder.id, folder); - } - let con: Connection = open_connection(); - for (folder_id, folder) in input_index.iter() { - // 1. skip if we're not in condensed_list; we were removed in an earlier step - if !condensed_list.contains_key(folder_id) { - continue; - } - // 2. get all parent folder IDs, take their tags for ourself, and remove those parents from condensed_list - let mut our_tag_titles = folder - .tags - .iter() - .map(|t| t.title.clone()) - .collect::>(); - let parents = match folder_repository::get_parent_folders_by_tag(*folder_id, &tags, &con) { - Ok(p) => p, - Err(e) => { - con.close().unwrap(); - log::error!( - "Failed to pull parent folders. Exception is {e}\n{}", - Backtrace::force_capture() - ); - return Err(GetFolderError::DbFailure); - } - }; - for (parent_id, parent_tags) in parents { - // if the parent has all of our tags, we need to remove ourself (and our children) - if contains_all(&parent_tags, tags) { - condensed_list.remove(folder_id); - // this will tell `give_children_tags` that we already have all the tags (which we do because our parent does), so all the children get removed - our_tag_titles = parent_tags; - break; - } - parent_tags.into_iter().for_each(|t| { - our_tag_titles.insert(t); - }); - condensed_list.remove(&parent_id); - } - // 3. + 4. get all children folder IDs, give them our tags, and remove ourself from condensed_list if we have children in condensed_list - if let Err(e) = - give_children_tags(&mut condensed_list, &con, *folder_id, &our_tag_titles, tags) - { - con.close().unwrap(); - return Err(e); - }; - // 5. remove ourself from condensed_list if we do not have all tags - if !contains_all(&our_tag_titles, tags) { - condensed_list.remove(folder_id); - } - } - con.close().unwrap(); - let copied: HashSet = condensed_list.into_values().collect(); - Ok(copied) -} - #[deprecated(note = "prefer to use the streaming version in preview_service")] pub async fn get_file_previews_for_folder( id: u32, ) -> Result>, GetBulkPreviewError> { let con: Connection = open_connection(); let ids: Vec = if id == 0 { vec![] } else { vec![id] }; - let file_ids: Vec = match folder_repository::get_child_files(ids, &con) { + let file_ids: Vec = match folder_repository::get_child_files(&ids, &con) { Ok(res) => res, Err(e) if e != rusqlite::Error::QueryReturnedNoRows => { con.close().unwrap(); @@ -382,73 +314,6 @@ pub fn download_folder(id: u32) -> Result { File::open(tarchive_dir.clone()).map_err(|_| DownloadFolderError::NotFound) } -// private functions -/// used as part of [reduce_folders_by_tag]; -/// handles giving all children our tags, and removing ourself if we have any children with tags we don't have -fn give_children_tags( - condensed_list: &mut HashMap, - con: &Connection, - folder_id: u32, - our_tag_titles: &HashSet, - tags: &HashSet, -) -> Result<(), GetFolderError> { - let all_child_folders_ids = match folder_repository::get_all_child_folder_ids(&[folder_id], con) - { - Ok(ids) => ids - .into_iter() - .filter(|id| condensed_list.contains_key(id)) - .collect::>(), - Err(e) => { - log::error!( - "Failed to retrieve all child folder IDs for {folder_id}. Exception is {e}\n{}", - Backtrace::force_capture() - ); - return Err(GetFolderError::DbFailure); - } - }; - // if we have all of the tags, remove all our children because they're not the highest - if contains_all(our_tag_titles, tags) { - for id in all_child_folders_ids.iter() { - condensed_list.remove(id); - } - return Ok(()); - } - for id in all_child_folders_ids.iter() { - let matching_folder = condensed_list.get_mut(id).unwrap(); - let matching_folder_tags = matching_folder.tags.clone(); - let combined_tag_titles = matching_folder_tags - .iter() - .map(|t| t.title.clone()) - .chain(our_tag_titles.clone().into_iter()); - let combined_tags = matching_folder_tags - .iter() - .map(|t| &t.title) - .chain(our_tag_titles.iter()) - .map(|title| TagApi { - id: None, - title: title.clone(), - }) - .collect::>(); - *matching_folder = FolderResponse { - id: matching_folder.id, - parent_id: matching_folder.parent_id, - path: matching_folder.path.clone(), - name: matching_folder.name.clone(), - folders: vec![], - files: vec![], - tags: combined_tags, - }; - // 4. remove all children who only have the same tags as us, because they're not the earliest with all tags (or they will never have all tags) - if HashSet::from_iter(combined_tag_titles) == *our_tag_titles { - condensed_list.remove(id); - } - } - if !all_child_folders_ids.is_empty() { - condensed_list.remove(&folder_id); - } - Ok(()) -} - fn get_folder_by_id(id: Option) -> Result { // the client can pass 0 for the folder id, in which case it needs to be translated to None for the database let db_folder = if let Some(0) = id { None } else { id }; @@ -649,7 +514,7 @@ fn does_file_exist( con: &Connection, ) -> Result { let unwrapped_id: Vec = folder_id.map(|it| vec![it]).unwrap_or_default(); - let matching_file = folder_repository::get_child_files(unwrapped_id, con)? + let matching_file = folder_repository::get_child_files(&unwrapped_id, con)? .iter() .find(|file| file.name == name.to_lowercase()) .cloned(); @@ -674,6 +539,78 @@ fn is_attempt_move_to_sub_child( } } +/// Handles the removal of implicit tags from descendants when a folder is moved. +/// +/// When a folder's parent changes, this function removes implicit tags from all +/// descendant files and folders that originated from the old ancestor chain. +/// +/// ## Parameters +/// - `folder_id`: the id of the folder being moved +/// - `original_ancestors`: list of ancestor folder ids from before the move +/// +/// ## Returns +/// - `Ok(())` if tags were successfully removed +/// - `Err(UpdateFolderError)` if there was a database error +fn handle_folder_move_for_tags( + folder_id: u32, + original_ancestors: Vec, +) -> Result<(), UpdateFolderError> { + let con = repository::open_connection(); + + // Get all descendant folders of the moved folder, so that we can remove stale tags + let descendant_folders = match folder_repository::get_all_child_folder_ids(&[folder_id], &con) { + Ok(folders) => folders, + Err(e) => { + log::error!( + "Failed to retrieve descendant folders for folder {}. Error is {e:?}\n{}", + folder_id, + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(UpdateFolderError::DbFailure); + } + }; + + // Get all descendant files from the moved folder and its descendants + let mut all_folder_ids = descendant_folders.clone(); + all_folder_ids.push(folder_id); + let descendant_files: Vec = match folder_repository::get_child_files(&all_folder_ids, &con) + { + Ok(files) => files.into_iter().map(|f| f.id.unwrap()).collect(), + Err(e) => { + con.close().unwrap(); + log::error!( + "Failed to retrieve descendant files for folder {}. Error is {e:?}\n{}", + folder_id, + Backtrace::force_capture() + ); + return Err(UpdateFolderError::DbFailure); + } + }; + + // Include the moved folder itself in the list of folders to remove implicit tags from + let mut folders_to_update = descendant_folders.clone(); + folders_to_update.push(folder_id); + + // For each old ancestor, remove all implicit tags from that ancestor on the moved folder and its descendants + if let Err(e) = tag_repository::batch_remove_implicit_tags( + &descendant_files, + &folders_to_update, + &original_ancestors, + &con, + ) { + con.close().unwrap(); + log::error!( + "Failed to remove implicit tags after moving folder {folder_id}. Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(UpdateFolderError::TagError); + } + + con.close().unwrap(); + Ok(()) +} + /// returns the top-level files for the passed folder fn get_files_for_folder( id: Option, @@ -681,7 +618,7 @@ fn get_files_for_folder( ) -> Result, GetChildFilesError> { // now we can retrieve all the file records in this folder let unwrapped_id = id.map(|it| vec![it]).unwrap_or_default(); - let child_files = match folder_repository::get_child_files(unwrapped_id, con) { + let child_files = match folder_repository::get_child_files(&unwrapped_id, con) { Ok(files) => files, Err(e) => { log::error!( @@ -695,7 +632,7 @@ fn get_files_for_folder( .iter() .map(|f| f.id.expect("files pulled from database didn't have ID!")) .collect(); - let file_tags = match tag_repository::get_tags_on_files(file_ids, con) { + let file_tags = match tag_repository::get_all_tags_for_files(file_ids, con) { Ok(res) => res, Err(e) => { log::error!( @@ -707,12 +644,12 @@ fn get_files_for_folder( }; let mut result: Vec = Vec::new(); for file in child_files { - let tags: Vec = if file_tags.contains_key(&file.id.unwrap()) { + let tags = if file_tags.contains_key(&file.id.unwrap()) { file_tags.get(&file.id.unwrap()).unwrap().clone() } else { Vec::new() }; - let tags: Vec = tags.iter().map(|it| it.clone().into()).collect(); + let tags: Vec = tags.iter().cloned().map_into().collect(); result.push(FileApi::from_with_tags(file, tags)); } Ok(result) @@ -733,7 +670,7 @@ fn delete_folder_recursively(id: u32, con: &Connection) -> Result {} @@ -758,16 +695,10 @@ fn delete_folder_recursively(id: u32, con: &Connection) -> Result(first: &HashSet, second: &HashSet) -> bool { - let intersection: HashSet = first.intersection(second).cloned().collect(); - &intersection == second -} - #[cfg(test)] mod get_folder_tests { use crate::model::error::folder_errors::GetFolderError; - use crate::model::response::TagApi; + use crate::model::response::TaggedItemApi; use crate::model::response::folder_responses::FolderResponse; use crate::service::folder_service::get_folder; use crate::test::{cleanup, create_folder_db_entry, create_tag_folder, init_db_folder}; @@ -812,9 +743,10 @@ mod get_folder_tests { name: "test".to_string(), folders: vec![], files: vec![], - tags: vec![TagApi { - id: Some(1), + tags: vec![TaggedItemApi { + tag_id: Some(1), title: "tag1".to_string(), + implicit_from: None, }], }; assert_eq!(expected, get_folder(Some(1)).unwrap()); @@ -826,11 +758,14 @@ mod get_folder_tests { mod update_folder_tests { use crate::model::error::folder_errors::UpdateFolderError; use crate::model::request::folder_requests::UpdateFolderRequest; - use crate::model::response::TagApi; + use crate::model::response::TaggedItemApi; use crate::model::response::folder_responses::FolderResponse; + use crate::service::file_service; use crate::service::folder_service::{get_folder, update_folder}; + use crate::tags::service::{get_tags_on_file, update_file_tags}; use crate::test::{ - cleanup, create_folder_db_entry, create_folder_disk, create_tag_folder, init_db_folder, + cleanup, create_file_db_entry, create_folder_db_entry, create_folder_disk, + create_tag_folder, imply_tag_on_folder, init_db_folder, }; #[test] @@ -842,9 +777,10 @@ mod update_folder_tests { id: 1, name: "test".to_string(), parent_id: None, - tags: vec![TagApi { - id: None, + tags: vec![TaggedItemApi { + tag_id: None, title: "tag1".to_string(), + implicit_from: None, }], }) .unwrap(); @@ -855,9 +791,10 @@ mod update_folder_tests { name: "test".to_string(), folders: vec![], files: vec![], - tags: vec![TagApi { - id: Some(1), + tags: vec![TaggedItemApi { + tag_id: Some(1), title: "tag1".to_string(), + implicit_from: None, }], }; assert_eq!(expected, get_folder(Some(1)).unwrap()); @@ -907,344 +844,666 @@ mod update_folder_tests { assert_eq!(expected, get_folder(Some(1)).unwrap()); cleanup(); } -} -#[cfg(test)] -mod reduce_folders_by_tag_tests { - use std::collections::HashSet; + #[test] + fn update_folder_implies_tags_to_descendant_folders() { + init_db_folder(); + create_folder_db_entry("parent", None); + create_folder_disk("parent"); + create_folder_db_entry("child", Some(1)); + create_folder_disk("parent/child"); - use crate::model::response::TagApi; - use crate::model::response::folder_responses::FolderResponse; - use crate::service::folder_service::reduce_folders_by_tag; - use crate::test::{ - cleanup, create_file_db_entry, create_folder_db_entry, create_tag_folder, - create_tag_folders, init_db_folder, - }; + update_folder(&UpdateFolderRequest { + id: 1, + name: "parent".to_string(), + parent_id: None, + tags: vec![TaggedItemApi { + tag_id: None, + title: "tag1".to_string(), + implicit_from: None, + }], + }) + .unwrap(); + + // Check child folder has implicit tag + let child = get_folder(Some(2)).unwrap(); + let expected = TaggedItemApi { + tag_id: Some(1), + title: "tag1".to_string(), + implicit_from: Some(1), + }; + assert_eq!(child.tags.len(), 1); + assert_eq!(child.tags[0], expected); + cleanup(); + } #[test] - fn reduce_folders_by_tag_works() { + fn update_folder_implies_tags_to_descendant_files() { init_db_folder(); - create_folder_db_entry("A", None); // 1 - create_folder_db_entry("AB", Some(1)); // 2 - create_folder_db_entry("ABB", Some(1)); // 3 - create_folder_db_entry("AC", Some(2)); // 4 - create_folder_db_entry("Dummy5", None); // 5 - create_folder_db_entry("E", None); // 6 - create_folder_db_entry("EB", Some(6)); // 7 - create_folder_db_entry("EC", Some(7)); // 8 - create_folder_db_entry("Dummy9", None); // 9 - create_folder_db_entry("Dummy10", None); // 10 - create_folder_db_entry("Dummy11", None); // 11 - create_folder_db_entry("Dummy12", None); // 12 - create_folder_db_entry("Dummy13", None); // 13 - create_folder_db_entry("XA", None); // 14 - create_folder_db_entry("X", Some(14)); // 15 - create_folder_db_entry("Y", None); // 16 - create_folder_db_entry("Z", Some(16)); // 17 - create_tag_folders("tag1", vec![6, 16, 17, 2, 15, 14, 1]); - create_tag_folders("tag3", vec![4, 15, 8, 3]); - create_tag_folders("tag2", vec![2, 15, 3, 7]); - let folders = HashSet::from([ - FolderResponse { - id: 6, - parent_id: None, - path: "".to_string(), - name: "E".to_string(), - folders: vec![], - files: vec![], - tags: vec![TagApi { - id: None, - title: "tag1".to_string(), - }], - }, - FolderResponse { - id: 16, - parent_id: None, - path: "".to_string(), - name: "Y".to_string(), - folders: vec![], - files: vec![], - tags: vec![TagApi { - id: None, - title: "tag1".to_string(), - }], - }, - FolderResponse { - id: 4, - parent_id: Some(2), - path: "".to_string(), - name: "AC".to_string(), - folders: vec![], - files: vec![], - tags: vec![TagApi { - id: None, - title: "tag3".to_string(), - }], - }, - FolderResponse { - id: 17, - parent_id: Some(16), - path: "".to_string(), - name: "Z".to_string(), - folders: vec![], - files: vec![], - tags: vec![TagApi { - id: None, - title: "tag1".to_string(), - }], - }, - FolderResponse { - id: 2, - parent_id: Some(1), - path: "".to_string(), - name: "AB".to_string(), - folders: vec![], - files: vec![], - tags: vec![ - TagApi { - id: None, - title: "tag2".to_string(), - }, - TagApi { - id: None, - title: "tag1".to_string(), - }, - ], - }, - FolderResponse { - id: 15, - parent_id: Some(14), - path: "".to_string(), - name: "X".to_string(), - folders: vec![], - files: vec![], - tags: vec![ - TagApi { - id: None, - title: "tag3".to_string(), - }, - TagApi { - id: None, - title: "tag1".to_string(), - }, - TagApi { - id: None, - title: "tag2".to_string(), - }, - ], - }, - FolderResponse { - id: 8, - parent_id: Some(7), - path: "".to_string(), - name: "EC".to_string(), - folders: vec![], - files: vec![], - tags: vec![TagApi { - id: None, - title: "tag3".to_string(), - }], - }, - FolderResponse { - id: 3, - parent_id: Some(1), - path: "".to_string(), - name: "ABB".to_string(), - folders: vec![], - files: vec![], - tags: vec![ - TagApi { - id: None, - title: "tag2".to_string(), - }, - TagApi { - id: None, - title: "tag3".to_string(), - }, - ], - }, - FolderResponse { - id: 7, - parent_id: Some(6), - path: "".to_string(), - name: "EB".to_string(), - folders: vec![], - files: vec![], - tags: vec![TagApi { - id: None, - title: "tag2".to_string(), - }], - }, - FolderResponse { - id: 1, - parent_id: None, - path: "".to_string(), - name: "A".to_string(), - folders: vec![], - files: vec![], - tags: vec![TagApi { - id: None, - title: "tag1".to_string(), - }], - }, - FolderResponse { - id: 14, - parent_id: None, - path: "".to_string(), - name: "XA".to_string(), - folders: vec![], - files: vec![], - tags: vec![TagApi { - id: None, - title: "tag1".to_string(), - }], - }, - ]); + create_folder_db_entry("parent", None); + create_folder_disk("parent"); - let expected = HashSet::from([ - FolderResponse { - id: 4, - parent_id: Some(2), - path: "".to_string(), - name: "AC".to_string(), - folders: vec![], - files: vec![], - tags: vec![TagApi { - id: None, - title: "tag3".to_string(), - }], - }, - FolderResponse { - id: 8, - parent_id: Some(7), - path: "".to_string(), - name: "EC".to_string(), - folders: vec![], - files: vec![], - tags: vec![TagApi { - id: None, - title: "tag3".to_string(), - }], - }, - FolderResponse { - id: 15, - parent_id: Some(14), - path: "".to_string(), - name: "X".to_string(), - folders: vec![], - files: vec![], - tags: vec![ - TagApi { - id: None, - title: "tag1".to_string(), - }, - TagApi { - id: None, - title: "tag2".to_string(), - }, - TagApi { - id: None, - title: "tag3".to_string(), - }, - ], - }, - FolderResponse { - id: 3, - parent_id: Some(1), - path: "".to_string(), - name: "ABB".to_string(), - folders: vec![], - files: vec![], - tags: vec![ - TagApi { - id: None, - title: "tag2".to_string(), - }, - TagApi { - id: None, - title: "tag3".to_string(), - }, - ], - }, - ]) - .into_iter() - .map(|f| f.id) - .collect::>(); - - let actual = reduce_folders_by_tag( - &folders, - &HashSet::from(["tag1".to_string(), "tag2".to_string(), "tag3".to_string()]), + create_file_db_entry("file.txt", Some(1)); + + update_folder(&UpdateFolderRequest { + id: 1, + name: "parent".to_string(), + parent_id: None, + tags: vec![TaggedItemApi { + tag_id: None, + title: "tag1".to_string(), + implicit_from: None, + }], + }) + .unwrap(); + + // Check file has implicit tag + let file_tags = get_tags_on_file(1).unwrap(); + let expected = TaggedItemApi { + tag_id: Some(1), + title: "tag1".to_string(), + implicit_from: Some(1), + }; + assert_eq!(file_tags.len(), 1); + assert_eq!(file_tags[0], expected); + cleanup(); + } + + #[test] + fn update_folder_removes_implicit_tags_from_descendants() { + init_db_folder(); + create_folder_db_entry("parent", None); + create_folder_disk("parent"); + create_folder_db_entry("child", Some(1)); + create_folder_disk("parent/child"); + + // Add tag and propagate + update_folder(&UpdateFolderRequest { + id: 1, + name: "parent".to_string(), + parent_id: None, + tags: vec![TaggedItemApi { + tag_id: None, + title: "tag1".to_string(), + implicit_from: None, + }], + }) + .unwrap(); + + // Verify child has implicit tag + let child = get_folder(Some(2)).unwrap(); + assert_eq!(child.tags.len(), 1); + + // Remove tag from parent + update_folder(&UpdateFolderRequest { + id: 1, + name: "parent".to_string(), + parent_id: None, + tags: vec![], + }) + .unwrap(); + + // Verify child no longer has implicit tag + let child = get_folder(Some(2)).unwrap(); + assert_eq!(child.tags.len(), 0); + cleanup(); + } + + #[test] + fn moving_a_folder_to_root_removes_all_descendant_implicit_tags_from_original_ancestors() { + init_db_folder(); + // Create folder structure: grandparent, parent, child + create_folder_db_entry("grandparent", None); + create_folder_db_entry("parent", Some(1)); + create_folder_db_entry("child", Some(2)); + create_file_db_entry("child_file", Some(3)); + create_folder_disk("grandparent/parent/child"); + + // Create another separate parent folder + create_folder_db_entry("new_parent", None); + create_folder_disk("new_parent"); + + // Add a tag to grandparent - should be implicated on parent and child + update_folder(&UpdateFolderRequest { + id: 1, + name: "grandparent".to_string(), + parent_id: None, + tags: vec![TaggedItemApi { + tag_id: None, + title: "grandparent_tag".to_string(), + implicit_from: None, + }], + }) + .unwrap(); + + // Verify child has implicit tag from grandparent + let child_folder = get_folder(Some(3)).unwrap(); + assert_eq!(child_folder.tags.len(), 1); + assert_eq!(child_folder.tags[0].title, "grandparent_tag"); + assert_eq!(child_folder.tags[0].implicit_from, Some(1)); + let child_file = file_service::get_file_metadata(1).unwrap(); + assert_eq!(child_file.tags.len(), 1); + assert_eq!(child_file.tags[0].title, "grandparent_tag"); + assert_eq!(child_file.tags[0].implicit_from, Some(1)); + + // Now move parent folder to new_parent - should remove grandparent's implicit tag from descendants + update_folder(&UpdateFolderRequest { + id: 2, + name: "parent".to_string(), + parent_id: Some(4), // new_parent folder + tags: vec![], + }) + .unwrap(); + + // Verify child no longer has implicit tag from grandparent + let child = get_folder(Some(3)).unwrap(); + assert_eq!(child.tags.len(), 0); + let child_file = file_service::get_file_metadata(1).unwrap(); + assert_eq!(child_file.tags.len(), 0); + cleanup(); + } + + #[test] + fn moving_folder_does_not_remove_tags_from_unaffected_folders() { + init_db_folder(); + // Create folder structure: + // grandparent (with tag) + // ├── parent + // │ └── child + // └── sibling (should keep grandparent tag even after parent moves) + create_folder_db_entry("grandparent", None); + create_folder_disk("grandparent"); + create_folder_db_entry("parent", Some(1)); + create_folder_disk("grandparent/parent"); + create_folder_db_entry("child", Some(2)); + create_folder_disk("grandparent/parent/child"); + create_folder_db_entry("sibling", Some(1)); + create_folder_disk("grandparent/sibling"); + + // Add a tag to grandparent - should be implicated on parent, child, and sibling + update_folder(&UpdateFolderRequest { + id: 1, + name: "grandparent".to_string(), + parent_id: None, + tags: vec![TaggedItemApi { + tag_id: None, + title: "grandparent_tag".to_string(), + implicit_from: None, + }], + }) + .unwrap(); + + // Verify sibling and child both have implicit tag from grandparent + let sibling = get_folder(Some(4)).unwrap(); + assert_eq!(sibling.tags.len(), 1); + assert_eq!(sibling.tags[0].title, "grandparent_tag"); + assert_eq!(sibling.tags[0].implicit_from, Some(1)); + + let child = get_folder(Some(3)).unwrap(); + assert_eq!(child.tags.len(), 1); + + // Move parent folder to root (simulates moving away from grandparent) + update_folder(&UpdateFolderRequest { + id: 2, + name: "parent".to_string(), + parent_id: None, // moving to root + tags: vec![], + }) + .unwrap(); + + // Verify sibling STILL has implicit tag from grandparent (unaffected by move) + let sibling = get_folder(Some(4)).unwrap(); + assert_eq!( + sibling.tags.len(), + 1, + "Sibling should still have tag from grandparent" + ); + assert_eq!(sibling.tags[0].title, "grandparent_tag"); + assert_eq!(sibling.tags[0].implicit_from, Some(1)); + + // Verify child no longer has the tag (was moved out of grandparent) + let child = get_folder(Some(3)).unwrap(); + assert_eq!(child.tags.len(), 0, "Child should have no tags after move"); + cleanup(); + } + + #[test] + fn moving_folder_does_not_remove_explicit_tags_from_descendants() { + init_db_folder(); + // Create folder structure: grandparent, parent (child of grandparent), child (child of parent) + create_folder_db_entry("grandparent", None); + create_folder_disk("grandparent"); + create_folder_db_entry("parent", Some(1)); + create_folder_disk("grandparent/parent"); + create_folder_db_entry("child", Some(2)); + create_folder_disk("grandparent/parent/child"); + + create_file_db_entry("file.txt", Some(3)); + + // Add a tag to grandparent - should be implicated on parent and child + update_folder(&UpdateFolderRequest { + id: 1, + name: "grandparent".to_string(), + parent_id: None, + tags: vec![TaggedItemApi { + tag_id: None, + title: "grandparent_tag".to_string(), + implicit_from: None, + }], + }) + .unwrap(); + + // Add an explicit tag to child folder + update_folder(&UpdateFolderRequest { + id: 3, + name: "child".to_string(), + parent_id: Some(2), + tags: vec![TaggedItemApi { + tag_id: None, + title: "explicit_tag".to_string(), + implicit_from: None, + }], + }) + .unwrap(); + + // Add an explicit tag to the file + update_file_tags( + 1, + vec![TaggedItemApi { + tag_id: None, + title: "file_explicit_tag".to_string(), + implicit_from: None, + }], ) - .unwrap() - .into_iter() - .map(|f| f.id) - .collect::>(); - assert_eq!(expected, actual); + .unwrap(); + + // Verify child has both implicit and explicit tags before the move + let child = get_folder(Some(3)).unwrap(); + // Child should have: + // 1. grandparent_tag (implicit from grandparent id=1) + // 2. explicit_tag (explicit on child id=3) + // Note: When we set explicit tags on a folder, it propagates to descendants but not to the folder itself from parents + assert_eq!( + child.tags.len(), + 2, + "Child should have 2 tags: implicit from grandparent and explicit" + ); + + // Verify file has implicit and explicit tags before the move + let file_tags = get_tags_on_file(1).unwrap(); + // File should have multiple implicit tags from ancestors + assert!(file_tags.len() >= 2, "File should have at least 2 tags"); + + // Now move parent folder to root - should only remove grandparent's implicit tag + update_folder(&UpdateFolderRequest { + id: 2, + name: "parent".to_string(), + parent_id: None, // moving to root + tags: vec![], + }) + .unwrap(); + + // Verify child still has explicit tag but not implicit from grandparent + let child = get_folder(Some(3)).unwrap(); + assert_eq!( + child.tags.len(), + 1, + "Child should have 1 tag after move: just the explicit tag" + ); + assert_eq!(child.tags[0].title, "explicit_tag"); + assert_eq!(child.tags[0].implicit_from, None); + + // Verify file still has its explicit tag + let file_tags = get_tags_on_file(1).unwrap(); + let has_file_explicit = file_tags + .iter() + .any(|t| t.title == "file_explicit_tag" && t.implicit_from.is_none()); + assert!(has_file_explicit, "File should have its explicit tag"); + + // Verify file no longer has grandparent implicit tag + let has_grandparent_tag = file_tags.iter().any(|t| t.title == "grandparent_tag"); + assert!( + !has_grandparent_tag, + "File should not have grandparent tag after move" + ); cleanup(); } #[test] - fn reduce_folders_by_tag_keeps_first_folder_with_all_tags() { + fn update_folder_implies_tags_to_descendant_files_in_nested_structure() { init_db_folder(); - create_folder_db_entry("top", None); // 1 - create_folder_db_entry("middle", Some(1)); // 2 - create_folder_db_entry("bottom", Some(2)); // 3 - create_file_db_entry("top file", Some(1)); - create_file_db_entry("bottom file", Some(3)); - create_tag_folders("tag1", vec![1, 3]); // tag1 on top folder and bottom folder - create_tag_folder("tag2", 3); // tag2 only on bottom folder - let input_folders = HashSet::from([ - FolderResponse { - id: 2, - parent_id: Some(1), - name: "middle".to_string(), - path: "".to_string(), - folders: vec![], - files: vec![], - tags: vec![TagApi { - id: None, - title: "tag2".to_string(), - }], - }, - FolderResponse { - id: 3, - parent_id: Some(2), - name: "bottom".to_string(), - path: "".to_string(), - folders: vec![], - files: vec![], - tags: vec![TagApi { - id: None, - title: "tag1".to_string(), - }], - }, - FolderResponse { - id: 1, - parent_id: None, - name: "top".to_string(), - path: "".to_string(), - folders: vec![], - files: vec![], - tags: vec![ - TagApi { - id: None, - title: "tag1".to_string(), - }, - TagApi { - id: None, - title: "tag2".to_string(), - }, - ], - }, - ]); - let expected: HashSet = HashSet::::from([1u32]); - let actual: HashSet = - reduce_folders_by_tag(&input_folders, &HashSet::from(["tag1".to_string()])) - .unwrap() - .into_iter() - .map(|it| it.id) - .collect(); - assert_eq!(expected, actual); + create_folder_db_entry("parent", None); + create_folder_disk("parent"); + create_folder_db_entry("child", Some(1)); + create_folder_disk("parent/child"); + + create_file_db_entry("file_in_parent.txt", Some(1)); + create_file_db_entry("file_in_child.txt", Some(2)); + + update_folder(&UpdateFolderRequest { + id: 1, + name: "parent".to_string(), + parent_id: None, + tags: vec![TaggedItemApi { + tag_id: None, + title: "tag1".to_string(), + implicit_from: None, + }], + }) + .unwrap(); + + // Check both files have implicit tag + let file1_tags = get_tags_on_file(1).unwrap(); + let file2_tags = get_tags_on_file(2).unwrap(); + + let expected = TaggedItemApi { + tag_id: Some(1), + title: "tag1".to_string(), + implicit_from: Some(1), + }; + + assert_eq!(file1_tags.len(), 1); + assert_eq!(file1_tags[0], expected); + assert_eq!(file2_tags.len(), 1); + assert_eq!(file2_tags[0], expected); + cleanup(); + } + + #[test] + fn update_folder_removes_implicit_tags_from_descendant_files() { + init_db_folder(); + create_folder_db_entry("parent", None); + create_folder_disk("parent"); + create_file_db_entry("file.txt", Some(1)); + + // Add tag and propagate + update_folder(&UpdateFolderRequest { + id: 1, + name: "parent".to_string(), + parent_id: None, + tags: vec![TaggedItemApi { + tag_id: None, + title: "tag1".to_string(), + implicit_from: None, + }], + }) + .unwrap(); + + // Verify file has implicit tag + let file_tags = get_tags_on_file(1).unwrap(); + assert_eq!(file_tags.len(), 1); + + // Remove tag from parent + update_folder(&UpdateFolderRequest { + id: 1, + name: "parent".to_string(), + parent_id: None, + tags: vec![], + }) + .unwrap(); + + // Verify file no longer has implicit tag + let file_tags = get_tags_on_file(1).unwrap(); + assert_eq!(file_tags.len(), 0); + cleanup(); + } + + #[test] + fn moving_a_folder_removes_all_descendant_file_implicit_tags_from_original_ancestors() { + init_db_folder(); + // Create folder structure: grandparent, parent, child with files at each level + create_folder_db_entry("grandparent", None); + create_folder_db_entry("parent", Some(1)); + create_folder_db_entry("child", Some(2)); + create_folder_disk("grandparent/parent/child"); + + create_file_db_entry("file_in_grandparent.txt", Some(1)); + create_file_db_entry("file_in_parent.txt", Some(2)); + create_file_db_entry("file_in_child.txt", Some(3)); + + // Create another separate parent folder + create_folder_db_entry("new_parent", None); + create_folder_disk("new_parent"); + + // Add a tag to grandparent - should be implicated on all descendant files + update_folder(&UpdateFolderRequest { + id: 1, + name: "grandparent".to_string(), + parent_id: None, + tags: vec![TaggedItemApi { + tag_id: None, + title: "grandparent_tag".to_string(), + implicit_from: None, + }], + }) + .unwrap(); + + // Verify all files have implicit tag from grandparent + let file1_tags = get_tags_on_file(1).unwrap(); + let file2_tags = get_tags_on_file(2).unwrap(); + let file3_tags = get_tags_on_file(3).unwrap(); + + assert_eq!(file1_tags.len(), 1); + assert_eq!(file1_tags[0].title, "grandparent_tag"); + assert_eq!(file2_tags.len(), 1); + assert_eq!(file2_tags[0].title, "grandparent_tag"); + assert_eq!(file3_tags.len(), 1); + assert_eq!(file3_tags[0].title, "grandparent_tag"); + + // Now move parent folder to new_parent - should remove grandparent's implicit tag from parent's descendants + update_folder(&UpdateFolderRequest { + id: 2, + name: "parent".to_string(), + parent_id: Some(4), // new_parent folder + tags: vec![], + }) + .unwrap(); + + // Verify files in parent and child no longer have implicit tag from grandparent + let file2_tags = get_tags_on_file(2).unwrap(); + let file3_tags = get_tags_on_file(3).unwrap(); + assert_eq!(file2_tags.len(), 0); + assert_eq!(file3_tags.len(), 0); + + // File in grandparent should still have the tag + let file1_tags = get_tags_on_file(1).unwrap(); + assert_eq!(file1_tags.len(), 1); + assert_eq!(file1_tags[0].title, "grandparent_tag"); + cleanup(); + } + + #[test] + fn moving_folder_does_not_remove_file_tags_from_unaffected_files() { + init_db_folder(); + // Create folder structure with files + create_folder_db_entry("grandparent", None); + create_folder_disk("grandparent"); + create_folder_db_entry("parent", Some(1)); + create_folder_disk("grandparent/parent"); + create_folder_db_entry("child", Some(2)); + create_folder_disk("grandparent/parent/child"); + create_folder_db_entry("sibling", Some(1)); + create_folder_disk("grandparent/sibling"); + + create_file_db_entry("file_in_child.txt", Some(3)); + create_file_db_entry("file_in_sibling.txt", Some(4)); + + // Add a tag to grandparent + update_folder(&UpdateFolderRequest { + id: 1, + name: "grandparent".to_string(), + parent_id: None, + tags: vec![TaggedItemApi { + tag_id: None, + title: "grandparent_tag".to_string(), + implicit_from: None, + }], + }) + .unwrap(); + + // Verify both files have implicit tag from grandparent + let file_child_tags = get_tags_on_file(1).unwrap(); + let file_sibling_tags = get_tags_on_file(2).unwrap(); + + assert_eq!(file_child_tags.len(), 1); + assert_eq!(file_sibling_tags.len(), 1); + + // Move parent folder to root + update_folder(&UpdateFolderRequest { + id: 2, + name: "parent".to_string(), + parent_id: None, // moving to root + tags: vec![], + }) + .unwrap(); + + // Verify sibling file STILL has implicit tag from grandparent (unaffected by move) + let file_sibling_tags = get_tags_on_file(2).unwrap(); + assert_eq!( + file_sibling_tags.len(), + 1, + "Sibling file should still have tag from grandparent" + ); + assert_eq!(file_sibling_tags[0].title, "grandparent_tag"); + assert_eq!(file_sibling_tags[0].implicit_from, Some(1)); + + // Verify child file no longer has the tag (was moved out of grandparent) + let file_child_tags = get_tags_on_file(1).unwrap(); + assert_eq!( + file_child_tags.len(), + 0, + "Child file should have no tags after move" + ); + cleanup(); + } + + #[test] + fn moving_folder_does_not_remove_explicit_tags_from_descendant_files() { + init_db_folder(); + // Create folder structure: grandparent, parent, child + create_folder_db_entry("grandparent", None); + create_folder_disk("grandparent"); + create_folder_db_entry("parent", Some(1)); + create_folder_disk("grandparent/parent"); + create_folder_db_entry("child", Some(2)); + create_folder_disk("grandparent/parent/child"); + + create_file_db_entry("file.txt", Some(3)); + + // Add a tag to grandparent + update_folder(&UpdateFolderRequest { + id: 1, + name: "grandparent".to_string(), + parent_id: None, + tags: vec![TaggedItemApi { + tag_id: None, + title: "grandparent_tag".to_string(), + implicit_from: None, + }], + }) + .unwrap(); + + // Add an explicit tag to the file + update_file_tags( + 1, + vec![TaggedItemApi { + tag_id: None, + title: "file_explicit_tag".to_string(), + implicit_from: None, + }], + ) + .unwrap(); + + // Verify file has both implicit and explicit tags before the move + let file_tags = get_tags_on_file(1).unwrap(); + assert_eq!(file_tags.len(), 2, "File should have 2 tags before move"); + + // Now move parent folder to root - should only remove grandparent's implicit tag + update_folder(&UpdateFolderRequest { + id: 2, + name: "parent".to_string(), + parent_id: None, // moving to root + tags: vec![], + }) + .unwrap(); + + // Verify file still has its explicit tag + let file_tags = get_tags_on_file(1).unwrap(); + assert_eq!( + file_tags.len(), + 1, + "File should have 1 tag after move: just the explicit tag" + ); + assert_eq!(file_tags[0].title, "file_explicit_tag"); + assert_eq!(file_tags[0].implicit_from, None); + cleanup(); + } + + #[test] + fn update_folder_only_saves_explicit_tags_as_explicit() { + init_db_folder(); + // Create folder hierarchy: parent1 -> child, and parent2 + create_folder_db_entry("parent1", None); // id 1 + create_folder_db_entry("child", Some(1)); // id 2 + create_folder_db_entry("parent2", None); // id 3 + create_folder_disk("parent1/child"); + create_folder_disk("parent2"); + create_tag_folder("implicitTag", 1); + create_tag_folder("explicitTag", 2); + imply_tag_on_folder(1, 2, 1); + + // Verify child has both explicit and implicit tags + let child_before = get_folder(Some(2)).unwrap(); + assert_eq!( + child_before.tags.len(), + 2, + "Child should have 2 tags initially" + ); + println!("Tags before update: {:?}", child_before.tags); + + // Now move child to parent2, passing BOTH tags in the request + // The implicit tag should be marked as implicit in the request + // If update_folder doesn't filter, it will try to save the implicit tag as explicit + update_folder(&UpdateFolderRequest { + id: 2, + name: "child".to_string(), + parent_id: Some(3), // Move to parent2 + tags: vec![ + // this tag should be kept + TaggedItemApi { + tag_id: Some(2), + title: "explicitTag".to_string(), + implicit_from: None, + }, + // this tag should be removed since it's implicit + TaggedItemApi { + tag_id: Some(1), + title: "implicitTag".to_string(), + implicit_from: Some(1), + }, + ], + }) + .unwrap(); + + // Get the child folder again to check final state + let child_after = get_folder(Some(2)).unwrap(); + + // The child should ONLY have the explicit tag now + // The implicit tag from parent1 should have been removed because we moved away from parent1 + // (The move logic removes implicit tags from old ancestors) + assert_eq!( + child_after.tags.len(), + 1, + "Child should have only 1 tag after move" + ); + + // Verify it's the explicit tag + assert_eq!(child_after.tags[0].title, "explicitTag"); + assert_eq!( + child_after.tags[0].implicit_from, None, + "Tag should be explicit" + ); + cleanup(); } } @@ -1268,3 +1527,99 @@ mod download_folder_tests { cleanup(); } } + +#[cfg(test)] +mod create_folder_tests { + use rocket::tokio; + + use crate::model::request::folder_requests::CreateFolderRequest; + use crate::service::folder_service::{create_folder, get_folder}; + use crate::test::{ + cleanup, create_folder_db_entry, create_folder_disk, create_tag_folder, init_db_folder, + }; + + #[tokio::test] + async fn create_folder_should_implicate_all_ancestor_tags() { + init_db_folder(); + // Create folder hierarchy: A -> B -> C + create_folder_db_entry("A", None); // id 1 + create_folder_db_entry("B", Some(1)); // id 2 + create_folder_db_entry("C", Some(2)); // id 3 + create_folder_disk("A/B/C"); + + // Add tags to the ancestor folders + create_tag_folder("tagA", 1); // tag id 1 on folder A + create_tag_folder("tagB", 2); // tag id 2 on folder B + create_tag_folder("tagC", 3); // tag id 3 on folder C + + // Create a new folder D inside C using the service function + let request = CreateFolderRequest { + name: "D".to_string(), + parent_id: Some(3), // parent is C + }; + + create_folder(&request).await.unwrap(); + + // The new folder D should have id 4 + let folder = get_folder(Some(4)).unwrap(); + assert_eq!(folder.tags.len(), 3, "Folder D should have 3 ancestor tags"); + + // Verify each tag is present and is implied from the correct folder + let tag_a = folder + .tags + .iter() + .find(|t| t.title == "tagA") + .expect("tagA should be present"); + assert_eq!( + tag_a.implicit_from, + Some(1), + "tagA should be implied from folder A" + ); + + let tag_b = folder + .tags + .iter() + .find(|t| t.title == "tagB") + .expect("tagB should be present"); + assert_eq!( + tag_b.implicit_from, + Some(2), + "tagB should be implied from folder B" + ); + + let tag_c = folder + .tags + .iter() + .find(|t| t.title == "tagC") + .expect("tagC should be present"); + assert_eq!( + tag_c.implicit_from, + Some(3), + "tagC should be implied from folder C" + ); + + cleanup(); + } + + #[tokio::test] + async fn create_folder_should_implicate_no_tags_if_ancestor_is_root() { + init_db_folder(); + // Create a folder directly in root (parent_id = None or 0) + let request = CreateFolderRequest { + name: "test".to_string(), + parent_id: None, // parent is root + }; + + create_folder(&request).await.unwrap(); + + // The new folder should have id 1 + let folder = get_folder(Some(1)).unwrap(); + assert_eq!( + folder.tags.len(), + 0, + "Folder in root should have no implied tags" + ); + + cleanup(); + } +} diff --git a/src/service/mod.rs b/src/service/mod.rs index 163c227..0a1ce03 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -2,4 +2,3 @@ pub mod api_service; pub mod file_service; pub mod folder_service; pub mod search_service; -pub mod tag_service; diff --git a/src/service/search_service.rs b/src/service/search_service.rs index 0ebd568..37c2387 100644 --- a/src/service/search_service.rs +++ b/src/service/search_service.rs @@ -1,17 +1,14 @@ use std::backtrace::Backtrace; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use itertools::Itertools; use rusqlite::Connection; use crate::model::api::FileApi; use crate::model::error::file_errors::SearchFileError; -use crate::model::repository::FileRecord; use crate::model::request::attributes::AttributeSearch; -use crate::model::response::TagApi; -use crate::model::response::folder_responses::FolderResponse; -use crate::repository::{file_repository, folder_repository, open_connection, tag_repository}; -use crate::service::folder_service; +use crate::repository::{file_repository, open_connection}; +use crate::tags::repository as tag_repository; pub fn search_files( search_title: &str, @@ -72,7 +69,7 @@ pub fn search_files( } final_set = final_set.into_iter().unique_by(|f| f.id).collect(); // now make sure all files have their tags or else we'll get inconsistent response bodies - let tag_mapping = match tag_repository::get_tags_on_files( + let tag_mapping = match tag_repository::get_all_tags_for_files( final_set.iter().map(|f| f.id).collect(), &con, ) { @@ -128,205 +125,18 @@ fn search_files_by_tags( search_tags: &HashSet, con: &Connection, ) -> Result, SearchFileError> { - let mut matching_files: HashSet = HashSet::new(); - // 1): retrieve all files from the database that have all of the tags directly on them - let files_with_all_tags: HashSet = match get_files_by_all_tags(search_tags, con) { + let retrieved = file_repository::search_files_by_tags(search_tags, con); + let matching_files = match retrieved { Ok(f) => f, Err(e) => { log::error!( - "File search: Failed to retrieve all files by tags. Exception is {e:?}\n{}", + "Failed to search files by tags: {e:?}\n{:?}", Backtrace::force_capture() ); return Err(SearchFileError::DbError); } }; - for file in files_with_all_tags { - matching_files.insert(file); - } - // 2): retrieve all folders that have any passed tag - let folders_with_any_tag = match folder_service::get_folders_by_any_tag(search_tags) { - Ok(f) => f, - Err(_) => { - return Err(SearchFileError::TagError); - } - }; - // index of all our folders to make things easier to lookup - let mut folder_index: HashMap = HashMap::new(); - for folder in folders_with_any_tag.iter() { - folder_index.insert(folder.id, folder); - } - // 3): reduce all the folders to the first folder with all the applied tags - let reduced = match folder_service::reduce_folders_by_tag(&folders_with_any_tag, search_tags) { - Ok(folders) => folders, - Err(_) => { - log::error!("Failed to search files!\n{}", Backtrace::force_capture()); - return Err(SearchFileError::DbError); - } - }; - // 4): get all child files of all reduced folders and their children, because reduced folders have all the tags - let deduped_child_files: HashSet = match get_deduped_child_files(&reduced, con) { - Ok(files) => files, - Err(e) => { - log::error!( - "Failed to retrieve deduped child files. Exception is {e:?}\n{}", - Backtrace::force_capture() - ); - return Err(SearchFileError::DbError); - } - }; - // 5: for each folder not in reduced, retrieve all child files in all child folders that contain the remaining tags - let non_deduped_child_files: HashSet = match get_all_non_reduced_child_files( - search_tags, - &folder_index, - &folders_with_any_tag, - &reduced, - con, - ) { - Ok(files) => files, - Err(e) => { - log::error!( - "Failed to get child files for non-deduped folders. Exception is {e:?}\n{}", - Backtrace::force_capture() - ); - return Err(SearchFileError::DbError); - } - }; - for file in non_deduped_child_files { - matching_files.insert(file); - } - for file in deduped_child_files { - matching_files.insert(file); - } - Ok(matching_files) -} - -fn get_non_duped_folder_ids( - reduced: &HashSet, - folders_with_any_tag: &HashSet, - con: &Connection, -) -> Result, rusqlite::Error> { - let non_duped_base_folder_ids: HashSet = folders_with_any_tag - .difference(reduced) - .map(|f| f.id) - .collect(); - let non_duped_child_folder_ids: HashSet = - folder_repository::get_all_child_folder_ids(&non_duped_base_folder_ids, con)? - .into_iter() - .collect(); - let non_duped_folder_ids: HashSet = non_duped_base_folder_ids - .union(&non_duped_child_folder_ids) - .copied() - .collect(); - Ok(non_duped_folder_ids) -} - -fn get_files_by_all_tags( - search_tags: &HashSet, - con: &Connection, -) -> Result, rusqlite::Error> { - let mut converted_files: HashSet = HashSet::new(); - let files = file_repository::search_files_by_tags(search_tags, con)?; - for file in files { - let tags: Vec = tag_repository::get_tags_on_file(file.id.unwrap(), con)? - .into_iter() - .map(TagApi::from) - .collect(); - let api = FileApi::from_with_tags(file, tags); - converted_files.insert(api); - } - Ok(converted_files) -} - -fn get_deduped_child_files( - reduced: &HashSet, - con: &Connection, -) -> Result, rusqlite::Error> { - let reduced_ids: Vec = reduced.iter().map(|f| f.id).collect(); - let all_relevant_folder_ids: HashSet = - folder_repository::get_all_child_folder_ids(&reduced_ids, con)? - .into_iter() - .chain(reduced_ids) - .collect(); - let deduped_child_files = get_child_files(&all_relevant_folder_ids, con)?; - Ok(deduped_child_files) -} - -fn get_child_files( - ids: &HashSet, - con: &Connection, -) -> Result, rusqlite::Error> { - let files: HashSet = if ids.is_empty() { - HashSet::new() - } else { - folder_repository::get_child_files(ids.clone(), con)? - .into_iter() - .collect() - }; - let mut converted: HashSet = HashSet::new(); - for file in files { - let tags = tag_repository::get_tags_on_file(file.id.unwrap(), con)? - .into_iter() - .map(TagApi::from) - .collect(); - converted.insert(FileApi::from_with_tags(file, tags)); - } - Ok(converted) -} - -/// recursively retrieves all child files with the remaining tags in `search_tags` for each folder in `folders_with_any_tag` -fn get_all_non_reduced_child_files( - search_tags: &HashSet, - folder_index: &HashMap, - folders_with_any_tag: &HashSet, - reduced: &HashSet, - con: &Connection, -) -> Result, rusqlite::Error> { - let non_duped_folder_ids: HashSet = - get_non_duped_folder_ids(reduced, folders_with_any_tag, con)?; - // 5.1) retrieve all child files of all child folders (+ original folder) using method in #4.2 above - let remaining_child_files: HashSet = get_child_files(&non_duped_folder_ids, con)? - .into_iter() - .collect(); - let mut final_files: HashSet = HashSet::new(); - for file in remaining_child_files { - // TODO the file might not have a direct parent folder in the index, but could still have an ancestor folder in the index - // TODO is_ancestor_of method in repository layer, that takes a folder id and file id, and returns true if folder is an ancestor of the file - let parent_id = file.folder_id.unwrap_or_default(); - let parent_tags: HashSet = if let Some(parent_folder) = folder_index.get(&parent_id) - { - parent_folder - .tags - .iter() - .map(|it| it.title.clone()) - .collect() - } else { - // direct parent isn't in the index, meaning this file has a searched tag but a grandparent has other searched tags. We need to find which parent that was and return those tags - let parent_ids = folder_index.keys(); - let mut all_ancestor_tags: HashSet = HashSet::new(); - for parent_id in parent_ids { - if is_ancestor_of(*parent_id, &file, con)? { - let tag_titles = folder_index - .get(parent_id) - .expect("parent id somehow disappeared from map") - .tags - .iter() - .map(|it| it.title.clone()); - all_ancestor_tags.extend(tag_titles); - } - } - all_ancestor_tags - }; - // parent folder has all the tags, we don't need to check further files - if &parent_tags == search_tags { - continue; - } - let missing_tags: HashSet<&String> = search_tags.difference(&parent_tags).collect(); - let file_tags: HashSet<&String> = file.tags.iter().map(|tag| &tag.title).collect(); - if missing_tags == file_tags { - final_files.insert(file); - } - } - Ok(final_files) + Ok(matching_files.into_iter().map(|it| it.into()).collect()) } fn search_files_by_attributes( @@ -344,38 +154,6 @@ fn search_files_by_attributes( }) } -/// Checks if the passed `potential_ancestor_id` is a parent/grandparent/great grandparent/etc of the passed `file` -/// If the file has no parent id or `potential_ancestor_id` == `file.folder_id`, no database call is made. Otherwise, the database is -/// checked to see if `potential_ancestor_id` is an ancestor. -/// -/// This function does not close the connection passed to it. -fn is_ancestor_of( - potential_ancestor_id: u32, - file: &FileApi, - con: &Connection, -) -> Result { - if let Some(direct_parent_id) = file.folder_id { - // avoid having to make a db call if the potential ancestor is a direct parent - if direct_parent_id == potential_ancestor_id { - Ok(true) - } else { - match folder_repository::get_ancestor_folder_ids(direct_parent_id, con) { - Ok(parent_ids) => Ok(parent_ids.contains(&potential_ancestor_id)), - Err(e) => { - log::error!( - "Failed to get ancestor folder ids. Exception is {e}\n{}", - Backtrace::force_capture() - ); - Err(e) - } - } - } - } else { - // file is at root, so no folder will ever be its parent unless the ancestor id is also root - Ok(potential_ancestor_id == 0) - } -} - #[cfg(test)] mod search_files_tests { use std::collections::HashSet; @@ -390,10 +168,10 @@ mod search_files_tests { AttributeSearch, AttributeTypes, EqualityOperator, NamedAttributes, NamedComparisonAttribute, }; - use crate::model::response::TagApi; + use crate::model::response::TaggedItemApi; use crate::test::{ cleanup, create_file_db_entry, create_folder_db_entry, create_tag_file, create_tag_files, - create_tag_folder, create_tag_folders, init_db_folder, + create_tag_folder, create_tag_folders, imply_tag_on_file, init_db_folder, }; #[test] @@ -439,13 +217,15 @@ mod search_files_tests { assert_eq!( res.tags, vec![ - TagApi { - id: Some(1), + TaggedItemApi { + tag_id: Some(1), title: "tag1".to_string(), + implicit_from: None, }, - TagApi { - id: Some(2), + TaggedItemApi { + tag_id: Some(2), title: "tag".to_string(), + implicit_from: None, } ] ); @@ -471,9 +251,10 @@ mod search_files_tests { assert_eq!(res.folder_id, None); assert_eq!( res.tags, - vec![TagApi { - id: Some(1), + vec![TaggedItemApi { + tag_id: Some(1), title: "tag".to_string(), + implicit_from: None, }] ); assert_eq!(res.file_type, Some(FileTypes::Unknown)); @@ -491,16 +272,24 @@ mod search_files_tests { create_file_db_entry("bottom file", Some(3)); create_tag_folders("tag1", vec![1, 3]); // tag1 on top folder and bottom folder create_tag_folder("tag2", 3); // tag2 only on bottom folder + imply_tag_on_file(1, 1, 1); + imply_tag_on_file(1, 2, 3); + imply_tag_on_file(2, 2, 3); // tag1 should retrieve all files let res = search_files("", vec!["tag1".to_string()], vec![].try_into().unwrap()).unwrap(); // we have to convert res to a vec in order to not care about the create date, since hash set `contains` relies on hash let res: Vec = res.iter().cloned().collect(); + log::debug!("first round: {res:?}"); assert_eq!(2, res.len()); assert!(res.contains(&FileApi { id: 1, name: "top file".to_string(), folder_id: Some(1), - tags: vec![], + tags: vec![TaggedItemApi { + tag_id: Some(1), + title: "tag1".to_string(), + implicit_from: Some(1) + }], size: Some(0), date_created: None, file_type: Some(FileTypes::Unknown) @@ -509,18 +298,41 @@ mod search_files_tests { id: 2, name: "bottom file".to_string(), folder_id: Some(3), - tags: vec![], + tags: vec![ + TaggedItemApi { + tag_id: Some(1), + title: "tag1".to_string(), + implicit_from: Some(3) + }, + TaggedItemApi { + tag_id: Some(2), + title: "tag2".to_string(), + implicit_from: Some(3) + } + ], size: Some(0), date_created: None, file_type: Some(FileTypes::Unknown) })); let res = search_files("", vec!["tag2".to_string()], vec![].try_into().unwrap()).unwrap(); let res: Vec = res.iter().cloned().collect(); + log::debug!("{res:?}"); assert!(res.contains(&FileApi { id: 2, name: "bottom file".to_string(), folder_id: Some(3), - tags: vec![], + tags: vec![ + TaggedItemApi { + tag_id: Some(1), + title: "tag1".to_string(), + implicit_from: Some(3) + }, + TaggedItemApi { + tag_id: Some(2), + title: "tag2".to_string(), + implicit_from: Some(3) + } + ], size: Some(0), date_created: None, file_type: Some(FileTypes::Unknown) @@ -536,6 +348,7 @@ mod search_files_tests { create_file_db_entry("bad", Some(1)); create_tag_folders("tag1", vec![1]); create_tag_file("tag2", 1); + imply_tag_on_file(1, 1, 1); let res: HashSet = search_files( "", vec!["tag1".to_string(), "tag2".to_string()], @@ -559,35 +372,39 @@ mod search_files_tests { id: 1, folder_id: Some(2), name: "good".to_string(), - tags: vec![TagApi { - id: None, + tags: vec![TaggedItemApi { + tag_id: Some(1), title: "file".to_string(), + implicit_from: Some(1), }], size: Some(0), date_created: Some(NaiveDateTime::default()), file_type: Some(FileTypes::Unknown), } .save_to_db(); + imply_tag_on_file(1, 1, 1); FileApi { id: 2, folder_id: Some(2), name: "bad".to_string(), - tags: vec![TagApi { - id: None, + tags: vec![TaggedItemApi { + tag_id: None, title: "something_else".to_string(), + implicit_from: None, }], size: None, date_created: None, file_type: None, } .save_to_db(); - let res = search_files( + let res: HashSet = search_files( "", vec!["top".to_string(), "file".to_string()], vec![].try_into().unwrap(), ) + .map(|it| it.iter().map(|i| i.id).collect()) .unwrap(); - let expected = HashSet::from_iter(vec![good_file]); + let expected: HashSet = HashSet::from_iter(vec![good_file.id]); assert_eq!(expected, res); cleanup(); } diff --git a/src/service/tag_service.rs b/src/service/tag_service.rs deleted file mode 100644 index 46882a4..0000000 --- a/src/service/tag_service.rs +++ /dev/null @@ -1,950 +0,0 @@ -use std::backtrace::Backtrace; -use std::collections::HashSet; - -use crate::model::error::file_errors::GetFileError; -use crate::model::error::tag_errors::{ - CreateTagError, DeleteTagError, GetTagError, TagRelationError, UpdateTagError, -}; -use crate::model::repository; -use crate::model::response::TagApi; -use crate::repository::{open_connection, tag_repository}; -use crate::service::{file_service, folder_service}; - -/// will create a tag, or return the already-existing tag if one with the same name exists -/// returns the created/existing tag -pub fn create_tag(name: String) -> Result { - let con = open_connection(); - let existing_tag: Option = match tag_repository::get_tag_by_title(&name, &con) - { - Ok(tags) => tags, - Err(e) => { - log::error!( - "Failed to check if any tags with the name {name} already exist! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(CreateTagError::DbError); - } - }; - let tag: repository::Tag = if let Some(t) = existing_tag { - t - } else { - match tag_repository::create_tag(&name, &con) { - Ok(t) => t, - Err(e) => { - log::error!( - "Failed to create a new tag with the name {name}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(CreateTagError::DbError); - } - } - }; - - con.close().unwrap(); - Ok(TagApi::from(tag)) -} - -/// will return the tag with the passed id -pub fn get_tag(id: u32) -> Result { - let con = open_connection(); - let tag: repository::Tag = match tag_repository::get_tag(id, &con) { - Ok(t) => t, - Err(rusqlite::Error::QueryReturnedNoRows) => { - log::error!( - "No tag with id {id} exists!\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(GetTagError::TagNotFound); - } - Err(e) => { - log::error!( - "Could not retrieve tag with id {id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(GetTagError::DbError); - } - }; - con.close().unwrap(); - Ok(TagApi::from(tag)) -} - -/// updates the tag with the passed id to the passed name. -/// Will fail if a tag already exists with that name -pub fn update_tag(request: TagApi) -> Result { - let con: rusqlite::Connection = open_connection(); - // make sure the tag exists first TODO cleanup - use if let Err pattern since Ok branch is empty - match tag_repository::get_tag(request.id.unwrap(), &con) { - Ok(_) => { /* no op */ } - Err(rusqlite::Error::QueryReturnedNoRows) => { - log::error!( - "Could not update tag with id {:?}, because it does not exist!\n{}", - request.id, - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(UpdateTagError::TagNotFound); - } - Err(e) => { - log::error!( - "Could not update tag with id {:?}! Error is {e}\n{}", - request.id, - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(UpdateTagError::DbError); - } - }; - let new_title = request.title; - // now make sure the database doesn't already have a tag with the new name TODO maybe see if can clean up, 2 empty branches is a smell - match tag_repository::get_tag_by_title(&new_title, &con) { - Ok(Some(_)) => { - log::error!( - "Could not update tag with id {:?} to name {new_title}, because a tag with that name already exists!\n{}", - request.id, - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(UpdateTagError::NewNameAlreadyExists); - } - Ok(None) => {} - Err(rusqlite::Error::QueryReturnedNoRows) => { /* this is the good route - no op */ } - Err(e) => { - log::error!( - "Could not search tags by name with value {new_title}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(UpdateTagError::DbError); - } - }; - // no match, and tag already exists so we're good to go - let db_tag = repository::Tag { - id: request.id.unwrap(), - title: new_title.clone(), - }; - match tag_repository::update_tag(db_tag, &con) { - Ok(()) => {} - Err(e) => { - log::error!( - "Could not update tag with id {:?}! Error is {e}\n{}", - request.id, - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(UpdateTagError::DbError); - } - }; - con.close().unwrap(); - Ok(TagApi { - id: request.id, - title: new_title, - }) -} - -/// deletes the tag with the passed id. Does nothing if that tag doesn't exist -pub fn delete_tag(id: u32) -> Result<(), DeleteTagError> { - let con: rusqlite::Connection = open_connection(); - // TODO change to if let Err pattern, Ok branch is empty - match tag_repository::delete_tag(id, &con) { - Ok(()) => {} - Err(e) => { - log::error!( - "Could not delete tag with id {id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(DeleteTagError::DbError); - } - }; - con.close().unwrap(); - Ok(()) -} - -/// Updates the tags on a file by replacing all existing tags with the provided list. -/// -/// This function will: -/// 1. Remove all existing tags from the file -/// 2. Add tags that already exist in the database (those with an `id`) -/// 3. Create and add new tags (those without an `id`) -/// -/// Duplicate tags in the input list will be automatically deduplicated to prevent -/// database constraint violations. -/// -/// # Parameters -/// - `file_id`: The ID of the file to update tags for -/// - `tags`: A vector of tags to set on the file. Tags with an `id` will be linked directly, -/// tags without an `id` will be created first (or retrieved if they already exist by name) -/// -/// # Returns -/// - `Ok(())` if the tags were successfully updated -/// - `Err(TagRelationError::FileNotFound)` if the file does not exist -/// - `Err(TagRelationError::DbError)` if there was a database error -pub fn update_file_tags(file_id: u32, tags: Vec) -> Result<(), TagRelationError> { - // make sure the file exists - if Err(GetFileError::NotFound) == file_service::get_file_metadata(file_id) { - log::error!( - "Cannot update tag for file {file_id}, because that file does not exist!\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::FileNotFound); - } - let existing_tags = get_tags_on_file(file_id)?; - let con: rusqlite::Connection = open_connection(); - // Remove all existing tags from the file - for tag in existing_tags.iter() { - // tags from the db will always have a non-None tag id - if let Err(e) = tag_repository::remove_tag_from_file(file_id, tag.id.unwrap(), &con) { - log::error!( - "Failed to remove tag from file with id {file_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(TagRelationError::DbError); - } - } - - // Track which tag IDs have been added to avoid duplicates - let mut added_tag_ids: HashSet = HashSet::new(); - - // First, add all existing tags (those with an id) - let existing_tags: Vec<&TagApi> = tags.iter().filter(|t| t.id.is_some()).collect(); - for tag in existing_tags { - let tag_id = tag.id.unwrap(); - // Skip if we've already added this tag - if added_tag_ids.contains(&tag_id) { - continue; - } - if let Err(e) = tag_repository::add_tag_to_file(file_id, tag_id, &con) { - log::error!( - "Failed to add tag to file with id {file_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(TagRelationError::DbError); - } - added_tag_ids.insert(tag_id); - } - - // Then, create and add new tags (those without an id) - let new_tags: Vec<&TagApi> = tags.iter().filter(|t| t.id.is_none()).collect(); - for tag in new_tags { - let created_tag = match create_tag(tag.title.clone()) { - Ok(t) => t, - Err(_) => { - con.close().unwrap(); - return Err(TagRelationError::DbError); - } - }; - let tag_id = created_tag.id.unwrap(); - // Skip if we've already added this tag (prevents duplicates) - if added_tag_ids.contains(&tag_id) { - continue; - } - if let Err(e) = tag_repository::add_tag_to_file(file_id, tag_id, &con) { - log::error!( - "Failed to add tag to file with id {file_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(TagRelationError::DbError); - } - added_tag_ids.insert(tag_id); - } - - con.close().unwrap(); - Ok(()) -} - -/// Updates the tags on a folder by replacing all existing tags with the provided list. -/// -/// This function will: -/// 1. Remove all existing tags from the folder -/// 2. Add tags that already exist in the database (those with an `id`) -/// 3. Create and add new tags (those without an `id`) -/// -/// Duplicate tags in the input list will be automatically deduplicated to prevent -/// database constraint violations. -/// -/// # Parameters -/// - `folder_id`: The ID of the folder to update tags for -/// - `tags`: A vector of tags to set on the folder. Tags with an `id` will be linked directly, -/// tags without an `id` will be created first (or retrieved if they already exist by name) -/// -/// # Returns -/// - `Ok(())` if the tags were successfully updated -/// - `Err(TagRelationError::FolderNotFound)` if the folder does not exist -/// - `Err(TagRelationError::DbError)` if there was a database error -pub fn update_folder_tags(folder_id: u32, tags: Vec) -> Result<(), TagRelationError> { - // make sure the file exists - if !folder_service::folder_exists(Some(folder_id)) { - log::error!("Cannot update tags for a folder that does not exist (id {folder_id}!"); - return Err(TagRelationError::FolderNotFound); - } - let existing_tags = get_tags_on_folder(folder_id)?; - let con: rusqlite::Connection = open_connection(); - // Remove all existing tags from the folder - for tag in existing_tags.iter() { - // tags from the db will always have a non-None tag id - if let Err(e) = tag_repository::remove_tag_from_folder(folder_id, tag.id.unwrap(), &con) { - log::error!( - "Failed to remove tags from folder with id {folder_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(TagRelationError::DbError); - } - } - - // Track which tag IDs have been added to avoid duplicates - let mut added_tag_ids: HashSet = HashSet::new(); - - // First, add all existing tags (those with an id) - let existing_tags: Vec<&TagApi> = tags.iter().filter(|t| t.id.is_some()).collect(); - for tag in existing_tags { - let tag_id = tag.id.unwrap(); - // Skip if we've already added this tag - if added_tag_ids.contains(&tag_id) { - continue; - } - if let Err(e) = tag_repository::add_tag_to_folder(folder_id, tag_id, &con) { - log::error!( - "Failed to add tags to folder with id {folder_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(TagRelationError::DbError); - } - added_tag_ids.insert(tag_id); - } - - // Then, create and add new tags (those without an id) - let new_tags: Vec<&TagApi> = tags.iter().filter(|t| t.id.is_none()).collect(); - for tag in new_tags { - let created_tag = match create_tag(tag.title.clone()) { - Ok(t) => t, - Err(e) => { - log::error!( - "Failed to create tag! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(TagRelationError::DbError); - } - }; - let tag_id = created_tag.id.unwrap(); - // Skip if we've already added this tag (prevents duplicates) - if added_tag_ids.contains(&tag_id) { - continue; - } - if let Err(e) = tag_repository::add_tag_to_folder(folder_id, tag_id, &con) { - log::error!( - "Failed to add tags to folder with id {folder_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(TagRelationError::DbError); - } - added_tag_ids.insert(tag_id); - } - - con.close().unwrap(); - Ok(()) -} - -/// retrieves all the tags on the file with the passed id -pub fn get_tags_on_file(file_id: u32) -> Result, TagRelationError> { - // make sure the file exists - if !file_service::check_file_exists(file_id) { - log::error!( - "Cannot get tags on file with id {file_id}, because that file does not exist!\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::FileNotFound); - } - let con: rusqlite::Connection = open_connection(); - let file_tags = match tag_repository::get_tags_on_file(file_id, &con) { - Ok(tags) => tags, - Err(e) => { - log::error!( - "Failed to retrieve tags on file with id {file_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(TagRelationError::DbError); - } - }; - con.close().unwrap(); - let api_tags: Vec = file_tags.into_iter().map(TagApi::from).collect(); - Ok(api_tags) -} - -/// retrieves all the tags on the folder with the passed id. -/// This will always be empty if requesting with the root folder id (0 or None) -pub fn get_tags_on_folder(folder_id: u32) -> Result, TagRelationError> { - // make sure the folder exists - if !folder_service::folder_exists(Some(folder_id)) { - log::error!( - "Cannot get tags on folder with id {folder_id}, because that folder does not exist!\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::FileNotFound); - } - let con: rusqlite::Connection = open_connection(); - let db_tags = match tag_repository::get_tags_on_folder(folder_id, &con) { - Ok(tags) => tags, - Err(e) => { - log::error!( - "Failed to retrieve tags on folder with id {folder_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(TagRelationError::DbError); - } - }; - con.close().unwrap(); - let api_tags: Vec = db_tags.into_iter().map(TagApi::from).collect(); - Ok(api_tags) -} - -#[cfg(test)] -mod get_tag_tests { - use crate::model::error::tag_errors::GetTagError; - use crate::service::tag_service::{create_tag, get_tag}; - use crate::test::*; - - #[test] - fn test_get_tag() { - init_db_folder(); - let expected = create_tag("test".to_string()).unwrap(); - let actual = get_tag(1).unwrap(); - assert_eq!(actual, expected); - cleanup(); - } - - #[test] - fn test_get_tag_non_existent() { - init_db_folder(); - let res = get_tag(1).expect_err("Retrieving a nonexistent tag should return an error"); - assert_eq!(GetTagError::TagNotFound, res); - cleanup(); - } -} - -#[cfg(test)] -mod update_tag_tests { - use crate::model::error::tag_errors::UpdateTagError; - use crate::model::response::TagApi; - use crate::service::tag_service::{create_tag, get_tag, update_tag}; - use crate::test::{cleanup, init_db_folder}; - - #[test] - fn update_tag_works() { - init_db_folder(); - let tag = create_tag("test_tag".to_string()).unwrap(); - let updated_tag = update_tag(TagApi { - id: tag.id, - title: "new_name".to_string(), - }) - .unwrap(); - assert_eq!(String::from("new_name"), updated_tag.title); - assert_eq!(Some(1), updated_tag.id); - // test that it's in the database - let updated_tag = get_tag(1).unwrap(); - assert_eq!(String::from("new_name"), updated_tag.title); - cleanup(); - } - - #[test] - fn update_tag_not_found() { - init_db_folder(); - let res = update_tag(TagApi { - id: Some(1), - title: "what".to_string(), - }); - assert_eq!(UpdateTagError::TagNotFound, res.unwrap_err()); - cleanup(); - } - - #[test] - fn update_tag_already_exists() { - init_db_folder(); - create_tag("first".to_string()).unwrap(); - create_tag("second".to_string()).unwrap(); - let res = update_tag(TagApi { - id: Some(2), - title: "FiRsT".to_string(), - }); - assert_eq!(UpdateTagError::NewNameAlreadyExists, res.unwrap_err()); - cleanup(); - } -} - -#[cfg(test)] -mod delete_tag_tests { - use crate::model::error::tag_errors::GetTagError; - use crate::service::tag_service::{create_tag, delete_tag, get_tag}; - use crate::test::{cleanup, init_db_folder}; - - #[test] - fn delete_tag_works() { - init_db_folder(); - create_tag("test".to_string()).unwrap(); - delete_tag(1).unwrap(); - let res = get_tag(1).unwrap_err(); - assert_eq!(GetTagError::TagNotFound, res); - cleanup(); - } -} - -#[cfg(test)] -mod update_file_tag_test { - use crate::model::error::tag_errors::TagRelationError; - use crate::model::file_types::FileTypes; - use crate::model::repository::FileRecord; - use crate::model::response::TagApi; - - use crate::service::tag_service::{create_tag, get_tags_on_file, update_file_tags}; - use crate::test::{cleanup, init_db_folder, now}; - - #[test] - fn update_file_tags_works() { - init_db_folder(); - create_tag("test".to_string()).unwrap(); - FileRecord { - id: None, - name: "test_file".to_string(), - parent_id: None, - size: 0, - create_date: now(), - file_type: FileTypes::Unknown, - } - .save_to_db(); - update_file_tags( - 1, - vec![ - TagApi { - id: Some(1), - title: "test".to_string(), - }, - TagApi { - id: None, - title: "new tag".to_string(), - }, - ], - ) - .unwrap(); - let expected = vec![ - TagApi { - id: Some(1), - title: "test".to_string(), - }, - TagApi { - id: Some(2), - title: "new tag".to_string(), - }, - ]; - let actual = get_tags_on_file(1).unwrap(); - assert_eq!(actual, expected); - cleanup(); - } - - #[test] - fn update_file_tags_removes_tags() { - init_db_folder(); - FileRecord { - id: None, - name: "test".to_string(), - parent_id: None, - size: 0, - create_date: now(), - file_type: FileTypes::Unknown, - } - .save_to_db(); - update_file_tags( - 1, - vec![TagApi { - id: None, - title: "test".to_string(), - }], - ) - .unwrap(); - update_file_tags(1, vec![]).unwrap(); - assert_eq!(get_tags_on_file(1).unwrap(), vec![]); - cleanup(); - } - - #[test] - fn update_file_tags_throws_error_if_file_not_found() { - init_db_folder(); - let res = update_file_tags(1, vec![]).unwrap_err(); - assert_eq!(TagRelationError::FileNotFound, res); - cleanup(); - } - - #[test] - fn update_file_tags_deduplicates_existing_tags() { - init_db_folder(); - create_tag("test".to_string()).unwrap(); - FileRecord { - id: None, - name: "test_file".to_string(), - parent_id: None, - size: 0, - create_date: now(), - file_type: FileTypes::Unknown, - } - .save_to_db(); - - // Try to add the same tag twice - should not fail and should only add it once - update_file_tags( - 1, - vec![ - TagApi { - id: Some(1), - title: "test".to_string(), - }, - TagApi { - id: Some(1), - title: "test".to_string(), - }, - ], - ) - .unwrap(); - - let actual = get_tags_on_file(1).unwrap(); - assert_eq!(actual.len(), 1); - assert_eq!(actual[0].id, Some(1)); - assert_eq!(actual[0].title, "test"); - cleanup(); - } - - #[test] - fn update_file_tags_deduplicates_new_tags_with_same_name() { - init_db_folder(); - FileRecord { - id: None, - name: "test_file".to_string(), - parent_id: None, - size: 0, - create_date: now(), - file_type: FileTypes::Unknown, - } - .save_to_db(); - - // Create tag implicitly by name twice - should only create once - update_file_tags( - 1, - vec![ - TagApi { - id: None, - title: "test".to_string(), - }, - TagApi { - id: None, - title: "test".to_string(), - }, - ], - ) - .unwrap(); - - let actual = get_tags_on_file(1).unwrap(); - assert_eq!(actual.len(), 1); - assert_eq!(actual[0].id, Some(1)); - assert_eq!(actual[0].title, "test"); - cleanup(); - } - - #[test] - fn update_file_tags_skips_duplicate_after_creating() { - init_db_folder(); - FileRecord { - id: None, - name: "test_file".to_string(), - parent_id: None, - size: 0, - create_date: now(), - file_type: FileTypes::Unknown, - } - .save_to_db(); - - // Mix of new tag by name and existing tag by id (same tag) - update_file_tags( - 1, - vec![TagApi { - id: None, - title: "test".to_string(), - }], - ) - .unwrap(); - - // Now update with both the id and a new tag with same name - update_file_tags( - 1, - vec![ - TagApi { - id: Some(1), - title: "test".to_string(), - }, - TagApi { - id: None, - title: "test".to_string(), - }, - ], - ) - .unwrap(); - - let actual = get_tags_on_file(1).unwrap(); - assert_eq!(actual.len(), 1); - assert_eq!(actual[0].id, Some(1)); - assert_eq!(actual[0].title, "test"); - cleanup(); - } -} - -#[cfg(test)] -mod update_folder_tag_test { - use crate::model::error::tag_errors::TagRelationError; - use crate::model::repository::Folder; - use crate::model::response::TagApi; - use crate::repository::{folder_repository, open_connection}; - use crate::service::tag_service::{create_tag, get_tags_on_folder, update_folder_tags}; - use crate::test::{cleanup, init_db_folder}; - - #[test] - fn update_folder_tags_works() { - init_db_folder(); - let con = open_connection(); - create_tag("test".to_string()).unwrap(); - folder_repository::create_folder( - &Folder { - parent_id: None, - id: None, - name: "test_file".to_string(), - }, - &con, - ) - .unwrap(); - con.close().unwrap(); - update_folder_tags( - 1, - vec![ - TagApi { - id: Some(1), - title: "test".to_string(), - }, - TagApi { - id: None, - title: "new tag".to_string(), - }, - ], - ) - .unwrap(); - let expected = vec![ - TagApi { - id: Some(1), - title: "test".to_string(), - }, - TagApi { - id: Some(2), - title: "new tag".to_string(), - }, - ]; - let actual = get_tags_on_folder(1).unwrap(); - assert_eq!(actual, expected); - cleanup(); - } - - #[test] - fn update_folder_tags_removes_tags() { - init_db_folder(); - let con = open_connection(); - folder_repository::create_folder( - &Folder { - parent_id: None, - id: None, - name: "test".to_string(), - }, - &con, - ) - .unwrap(); - con.close().unwrap(); - update_folder_tags( - 1, - vec![TagApi { - id: None, - title: "test".to_string(), - }], - ) - .unwrap(); - update_folder_tags(1, vec![]).unwrap(); - assert_eq!(get_tags_on_folder(1).unwrap(), vec![]); - cleanup(); - } - - #[test] - fn update_folder_tags_throws_error_if_folder_not_found() { - init_db_folder(); - let res = update_folder_tags(1, vec![]).unwrap_err(); - assert_eq!(TagRelationError::FolderNotFound, res); - cleanup(); - } - - #[test] - fn update_folder_tags_deduplicates_existing_tags() { - init_db_folder(); - let con = open_connection(); - create_tag("test".to_string()).unwrap(); - folder_repository::create_folder( - &Folder { - parent_id: None, - id: None, - name: "test_folder".to_string(), - }, - &con, - ) - .unwrap(); - con.close().unwrap(); - - // Try to add the same tag twice - should not fail and should only add it once - update_folder_tags( - 1, - vec![ - TagApi { - id: Some(1), - title: "test".to_string(), - }, - TagApi { - id: Some(1), - title: "test".to_string(), - }, - ], - ) - .unwrap(); - - let actual = get_tags_on_folder(1).unwrap(); - assert_eq!(actual.len(), 1); - assert_eq!(actual[0].id, Some(1)); - assert_eq!(actual[0].title, "test"); - cleanup(); - } - - #[test] - fn update_folder_tags_deduplicates_new_tags_with_same_name() { - init_db_folder(); - let con = open_connection(); - folder_repository::create_folder( - &Folder { - parent_id: None, - id: None, - name: "test_folder".to_string(), - }, - &con, - ) - .unwrap(); - con.close().unwrap(); - - // Create tag implicitly by name twice - should only create once - update_folder_tags( - 1, - vec![ - TagApi { - id: None, - title: "test".to_string(), - }, - TagApi { - id: None, - title: "test".to_string(), - }, - ], - ) - .unwrap(); - - let actual = get_tags_on_folder(1).unwrap(); - assert_eq!(actual.len(), 1); - assert_eq!(actual[0].id, Some(1)); - assert_eq!(actual[0].title, "test"); - cleanup(); - } - - #[test] - fn update_folder_tags_skips_duplicate_after_creating() { - init_db_folder(); - let con = open_connection(); - folder_repository::create_folder( - &Folder { - parent_id: None, - id: None, - name: "test_folder".to_string(), - }, - &con, - ) - .unwrap(); - con.close().unwrap(); - - // Mix of new tag by name and existing tag by id (same tag) - update_folder_tags( - 1, - vec![TagApi { - id: None, - title: "test".to_string(), - }], - ) - .unwrap(); - - // Now update with both the id and a new tag with same name - update_folder_tags( - 1, - vec![ - TagApi { - id: Some(1), - title: "test".to_string(), - }, - TagApi { - id: None, - title: "test".to_string(), - }, - ], - ) - .unwrap(); - - let actual = get_tags_on_folder(1).unwrap(); - assert_eq!(actual.len(), 1); - assert_eq!(actual[0].id, Some(1)); - assert_eq!(actual[0].title, "test"); - cleanup(); - } -} - -#[cfg(test)] -mod get_tags_on_file_tests { - use crate::model::error::tag_errors::TagRelationError; - use crate::service::tag_service::get_tags_on_file; - use crate::test::{cleanup, init_db_folder}; - - #[test] - fn throws_error_if_file_not_found() { - init_db_folder(); - let err = get_tags_on_file(1).unwrap_err(); - assert_eq!(TagRelationError::FileNotFound, err); - cleanup(); - } -} - -#[cfg(test)] -mod get_tags_on_folder_tests { - use crate::model::error::tag_errors::TagRelationError; - use crate::service::tag_service::get_tags_on_folder; - use crate::test::{cleanup, init_db_folder}; - - #[test] - fn throws_error_if_file_not_found() { - init_db_folder(); - let err = get_tags_on_folder(1).unwrap_err(); - assert_eq!(TagRelationError::FileNotFound, err); - cleanup(); - } -} diff --git a/src/handler/tag_handler.rs b/src/tags/handler.rs similarity index 95% rename from src/handler/tag_handler.rs rename to src/tags/handler.rs index f88d773..9a73265 100644 --- a/src/handler/tag_handler.rs +++ b/src/tags/handler.rs @@ -11,9 +11,10 @@ use crate::model::response::tag_responses::{ CreateTagResponse, DeleteTagResponse, GetTagResponse, UpdateTagResponse, }; use crate::model::response::{BasicMessage, TagApi}; -use crate::service::tag_service; use crate::util::update_last_request_time; +use super::service; + #[get("/")] pub fn get_tag( id: u32, @@ -26,7 +27,7 @@ pub fn get_tag( ValidateResult::Invalid => return GetTagResponse::Unauthorized("Bad Credentials".to_string()) }; update_last_request_time(last_request_time); - match tag_service::get_tag(id) { + match service::get_tag(id) { Ok(tag) => GetTagResponse::Success(Json::from(tag)), Err(GetTagError::TagNotFound) => GetTagResponse::TagNotFound(BasicMessage::new( "The tag with the passed id could not be found.", @@ -49,7 +50,7 @@ pub fn create_tag( ValidateResult::Invalid => return CreateTagResponse::Unauthorized("Bad Credentials".to_string()) }; update_last_request_time(last_request_time); - match tag_service::create_tag(tag.title.clone()) { + match service::create_tag(tag.title.clone()) { Ok(tag) => CreateTagResponse::Success(Json::from(tag)), Err(_) => CreateTagResponse::TagDbError(BasicMessage::new( "Failed to create tag info in database. Check server logs for details", @@ -69,7 +70,7 @@ pub fn update_tag( ValidateResult::Invalid => return UpdateTagResponse::Unauthorized("Bad Credentials".to_string()) }; update_last_request_time(last_request_time); - match tag_service::update_tag(tag.into_inner()) { + match service::update_tag(tag.into_inner()) { Ok(tag) => UpdateTagResponse::Success(Json::from(tag)), Err(UpdateTagError::TagNotFound) => { UpdateTagResponse::TagNotFound(BasicMessage::new("No tag with that id was found.")) @@ -95,7 +96,7 @@ pub fn delete_tag( ValidateResult::Invalid => return DeleteTagResponse::Unauthorized("Bad Credentials".to_string()) }; update_last_request_time(last_request_time); - match tag_service::delete_tag(id) { + match service::delete_tag(id) { Ok(()) => DeleteTagResponse::Success(()), Err(_) => DeleteTagResponse::TagDbError(BasicMessage::new( "Failed to delete tag from database. Check server logs for details.", diff --git a/src/tags/mod.rs b/src/tags/mod.rs new file mode 100644 index 0000000..a693eb5 --- /dev/null +++ b/src/tags/mod.rs @@ -0,0 +1,10 @@ +pub mod handler; +pub mod models; +pub mod repository; +pub mod service; + +#[cfg(test)] +mod tests; + +// make it easier to just use models +pub use models::*; diff --git a/src/tags/models.rs b/src/tags/models.rs new file mode 100644 index 0000000..b9e6201 --- /dev/null +++ b/src/tags/models.rs @@ -0,0 +1,37 @@ +/// lists the different types of tags that can exist on a file or folder +#[derive(Eq, PartialEq)] +pub enum TagTypes { + /// The tag was individually set on the file or folder + Explicit, + /// the tag was individually set on an ancestor folder + _Implicit, +} + +/// represents a tag in the Tags table of the database. When referencing a tag _on_ a file / folder, use [`TaggedItem`] instead +#[derive(Debug, PartialEq, Clone)] +pub struct Tag { + /// the id of the tag + pub id: u32, + /// the display name of the tag + pub title: String, +} + +/// represents a tag on a file or a folder, with optional implication. +/// These are not meant to ever be created outside of a database query retrieving it from the database +/// +/// [`file_id`] _or_ [`folder_id`] will be [`None`], but never both. [`implicit_from_id`] will be None if the tag is explicitly on the item +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] +pub struct TaggedItem { + /// the database id of this specific entry + pub id: u32, + /// if present, the id of the file this tag exists on. mutually exclusive with folder_id + pub file_id: Option, + /// if present, the id of the folder this tag exists on. mutually exclusive with file_id + pub folder_id: Option, + /// if present, the folder that implicates this tag on the file/folder this tag applies to + pub implicit_from_id: Option, + /// the tag's title + pub title: String, + /// the id of the actual tag + pub tag_id: u32, +} diff --git a/src/tags/repository.rs b/src/tags/repository.rs new file mode 100644 index 0000000..2df790b --- /dev/null +++ b/src/tags/repository.rs @@ -0,0 +1,484 @@ +use std::{backtrace::Backtrace, collections::HashMap}; + +use itertools::Itertools; +use rusqlite::Connection; +use std::fmt::Write; + +use crate::tags::TagTypes; + +use super::models; + +/// creates a new tag in the database. This does not check if the tag already exists, +/// so the caller must check that themselves +pub fn create_tag(title: &str, con: &Connection) -> Result { + let mut pst = con.prepare(include_str!("../assets/queries/tags/create_tag.sql"))?; + let id = pst.insert(rusqlite::params![title])? as u32; + Ok(models::Tag { + id, + title: title.to_string(), + }) +} + +/// searches for a tag that case-insensitively matches that passed title. +/// +/// if `None` is returned, that means there was no match +pub fn get_tag_by_title( + title: &str, + con: &Connection, +) -> Result, rusqlite::Error> { + let mut pst = con.prepare(include_str!("../assets/queries/tags/get_by_title.sql"))?; + match pst.query_row(rusqlite::params![title], tag_mapper) { + Ok(tag) => Ok(Some(tag)), + Err(e) => { + // no tag found + if e == rusqlite::Error::QueryReturnedNoRows { + Ok(None) + } else { + log::error!( + "Failed to get tag by name, error is {e:?}\n{}", + Backtrace::force_capture() + ); + Err(e) + } + } + } +} + +/// retrieves a tag from the database with the passed `id` +/// +/// # Parameters +/// - `id`: the unique identifier of the tag to retrieve +/// - `con`: the database connection to use. Callers must handle closing the connection +/// +/// # Returns +/// - `Ok(models::Tag)`: the tag with the specified ID if the tag exists +/// - `Err(rusqlite::Error)`: if there was an error during the database operation, including if no tag with the specified ID exists +pub fn get_tag(id: u32, con: &Connection) -> Result { + let mut pst = con.prepare(include_str!("../assets/queries/tags/get_by_id.sql"))?; + pst.query_row(rusqlite::params![id], tag_mapper) +} + +/// updates the past tag. Checking to make sure the tag exists needs to be done on the caller's end +pub fn update_tag(tag: models::Tag, con: &Connection) -> Result<(), rusqlite::Error> { + let mut pst = con.prepare(include_str!("../assets/queries/tags/update_tag.sql"))?; + pst.execute(rusqlite::params![tag.title, tag.id])?; + Ok(()) +} + +pub fn delete_tag(id: u32, con: &Connection) -> Result<(), rusqlite::Error> { + let mut pst = con.prepare(include_str!("../assets/queries/tags/delete_tag.sql"))?; + pst.execute(rusqlite::params![id])?; + Ok(()) +} + +// ================= file functions ================= +/// the caller of this function will need to make sure the tag already exists and isn't already on the file +pub fn add_explicit_tag_to_file( + file_id: u32, + tag_id: u32, + con: &Connection, +) -> Result<(), rusqlite::Error> { + let mut pst = con.prepare(include_str!("../assets/queries/tags/add_tag_to_file.sql"))?; + pst.execute(rusqlite::params![file_id, tag_id])?; + Ok(()) +} + +/// Adds an implicit tag to multiple files +/// +/// For each file, a tag is added _only_ if that file doesn't already have that tag (explicit or implicit) +/// +/// ## Parameters: +/// - `tag_id`: the id of the tag to add +/// - `file_ids`: the ids of the files to add the tag to +/// - `implicit_from_id`: the id of the folder that implicates the tag on the files +/// - `con`: a reference to a database connection. The caller must manage closing the connection. +/// +/// ## Returns: +/// will return a rusqlite error if a database interaction fails +/// +/// --- +/// See also: [`add_implicit_tag_to_file`] for adding to a single file +pub fn add_implicit_tag_to_files( + tag_id: u32, + file_ids: &[u32], + implicit_from_id: u32, + con: &Connection, +) -> Result<(), rusqlite::Error> { + add_implicit_tags_to_files(file_ids, &[tag_id], implicit_from_id, con) +} + +/// Adds multiple implicit tags to multiple files +/// +/// For each file, each tag is added _only_ if that file doesn't already have that tag (explicit or implicit). +/// The `insert or ignore` behavior ensures that: +/// - Explicit tags on the file are never overridden +/// - Tags from closer ancestors take precedence over tags from farther ancestors +/// +/// ## Parameters: +/// - `file_ids`: the ids of the files to add the tags to +/// - `tag_ids`: the ids of the tags to add +/// - `implicit_from_id`: the id of the folder that implicates the tags on the files +/// - `con`: a reference to a database connection. The caller must manage closing the connection. +/// +/// ## Returns: +/// will return a rusqlite error if a database interaction fails +pub fn add_implicit_tags_to_files( + file_ids: &[u32], + tag_ids: &[u32], + implicit_from_id: u32, + con: &Connection, +) -> Result<(), rusqlite::Error> { + if file_ids.is_empty() || tag_ids.is_empty() { + return Ok(()); + } + // the server is meant to run on low-powered hardware. I believe optimizations like this can help immensely + let initial_statement_length = 73; // based on manually checking the initial insert length + let file_id_len_estimate = 5; // reasonable after a long time to have gone through 6-digit ids for files, but 5 for now is fine + let tag_id_len_estimate = 4; // reasonable after a long time to reach 4-digit ids for tags + let folder_id_len_estimate = 4; // generally, more files than folders will be created; + let estimate_each_line_length = + file_id_len_estimate + tag_id_len_estimate + folder_id_len_estimate + 5; // extra 5 for commas etc + // the capacity is approximate and certainly will help with having to avoid excessive reallocations + let mut sql = String::with_capacity( + initial_statement_length + estimate_each_line_length * (tag_ids.len() * file_ids.len()), + ); + write!( + &mut sql, + "insert or ignore into TaggedItems(tagId, fileId, implicitFromId)\nvalues " + ) + .expect("String should never return an error on write! Something is wrong with rust or you heavily misunderstand what's going on"); + let mut past_first = false; + for file_id in file_ids { + for tag_id in tag_ids { + // need to add a comma to the beginning of each line that isn't the first + if past_first { + sql.push(','); + } + writeln!(&mut sql, "({tag_id}, {file_id}, {implicit_from_id})") + .expect("writing to a string should never fail! You should never see this"); + past_first = true; + } + } + con.execute_batch(&sql).and(Ok(())) +} + +/// Retrieves all tags on a file, explicit or implied +/// +/// ## Parameters: +/// - `file_id` the id of the file to get tags for +/// - `con` a reference to a database connection. This must be closed by the parent +/// +/// ## Returns: +/// - `Ok(Vec)`: a list of tags on the file +/// - `Err(rusqlite::Error)`: if there was an error during the database operation +/// +/// If the file doesn't exist or has no tags, an empty vec is returned +pub fn get_all_tags_for_file( + file_id: u32, + con: &Connection, +) -> Result, rusqlite::Error> { + let mut pst = con.prepare(include_str!( + "../assets/queries/tags/get_all_tags_for_file.sql" + ))?; + let rows = pst.query_map(rusqlite::params![file_id], tagged_item_mapper)?; + let mut tags: Vec = Vec::new(); + for tag_res in rows { + tags.push(tag_res?); + } + Ok(tags) +} + +/// Retrieves all tags for a file with the passed id and tag type +/// +/// ## Parameters +/// - `file_id`: the id of the file to get tags for +/// - `tag_type`: the type of tags to retrieve. If [`TagTypes::Explicit`] is passed, only tags explicitly passed on the file are returned. +/// If [`TagTypes::Implicit`] is passed, only implicated tags from parent folders are returned. +/// - `con`: a database connection to the database. Must be closed by the caller +/// +/// See Also: [`get_all_tags_for_file`] to get all tags regardless of type +pub fn _get_tags_for_file( + file_id: u32, + tag_type: TagTypes, + con: &Connection, +) -> Result, rusqlite::Error> { + let query = match tag_type { + TagTypes::Explicit => include_str!("../assets/queries/tags/get_explicit_tags_for_file.sql"), + TagTypes::_Implicit => { + include_str!("../assets/queries/tags/get_implicit_tags_for_file.sql") + } + }; + let mut pst = con.prepare(query)?; + let rows = pst.query_map(rusqlite::params![file_id], tagged_item_mapper)?; + rows.collect::, _>>() +} + +/// Retrieves all tags on all files passed, explicit or implied. +/// The returned value is a Map of file id => Vec<[`models::TaggedItem`]>. Files without _any_ tags will not have an entry in the map +/// +/// ## Parameters: +/// - `file_ids` the ids to get tags for +/// - `con` a reference to a database connection. The caller must manage closing the connection. +/// +/// ## Returns: +/// - `Ok(HashMap>)` if the tags were successfully retrieved +/// - `Err(rusqlite::Error)` if there was a database error +/// +/// --- +/// See also [`get_all_tags_for_file`] +/// +pub fn get_all_tags_for_files( + file_ids: Vec, + con: &Connection, +) -> Result>, rusqlite::Error> { + let in_clause: Vec = file_ids.iter().map(|it| format!("'{it}'")).collect(); + let in_clause = in_clause.join(","); + let formatted_query = format!( + include_str!("../assets/queries/tags/get_all_tags_for_files.sql"), + in_clause + ); + let mut pst = con.prepare(formatted_query.as_str())?; + let rows = pst.query_map([], tagged_item_mapper)?; + let mut mapped: HashMap> = HashMap::new(); + for tag in rows { + let tag = tag?; + let id = tag + .file_id + .expect("query should eliminate all non-file tags"); + mapped + .entry(id) + .and_modify(|tags| tags.push(tag.clone())) + .or_insert_with(|| vec![tag]); + } + Ok(mapped) +} + +/// removes the tag from the file if that file explicitly has that tag. +/// +/// implicit tags are not removed with this function +pub fn remove_explicit_tag_from_file( + file_id: u32, + tag_id: u32, + con: &Connection, +) -> Result<(), rusqlite::Error> { + let mut pst = con.prepare(include_str!( + "../assets/queries/tags/remove_explicit_tag_from_file.sql" + ))?; + pst.execute(rusqlite::params![file_id, tag_id])?; + Ok(()) +} + +// ================= folder functions ================= +pub fn add_explicit_tag_to_folder( + folder_id: u32, + tag_id: u32, + con: &Connection, +) -> Result<(), rusqlite::Error> { + let mut pst = con.prepare(include_str!("../assets/queries/tags/add_tag_to_folder.sql"))?; + pst.execute(rusqlite::params![folder_id, tag_id])?; + Ok(()) +} + +/// Adds an implicit tag to multiple folders +/// +/// For each folder, a tag is added _only_ if that folder doesn't already have that tag (explicit or implicit) +/// +/// ## Parameters: +/// - `tag_id`: the id of the tag to add +/// - `folder_ids`: the ids of the folders to add the tag to +/// - `implicit_from_id`: the id of the folder that implicates the tag on the folders +/// - `con`: a reference to a database connection. The caller must manage closing the connection. +/// +/// ## Returns: +/// will return a rusqlite error if a database interaction fails +/// +/// --- +/// See also: [`add_implicit_tag_to_folder`] for adding to a single folder +pub fn add_implicit_tag_to_folders( + tag_id: u32, + folder_ids: &[u32], + implicit_from_id: u32, + con: &Connection, +) -> Result<(), rusqlite::Error> { + let mut pst = con.prepare(include_str!( + "../assets/queries/tags/add_implicit_tag_to_folder.sql" + ))?; + for folder_id in folder_ids { + pst.execute(rusqlite::params![tag_id, folder_id, implicit_from_id])?; + } + Ok(()) +} + +/// Retrieves all tags on the folder with the passed id, explicit or implied. +/// If no folder is found, an empty Vec is returned. +/// +/// ## Parameters: +/// - `folder_id` the id of the folder in the database to retrieve tags for +/// - `con` a reference to a database connection. The caller must manage closing the connection. +/// +/// ## Returns: +/// - `Ok(Vec)` if the tags were successfully retrieved +/// - `Err(rusqlite::Error)` if there was a database error +pub fn get_all_tags_for_folder( + folder_id: u32, + con: &Connection, +) -> Result, rusqlite::Error> { + let mut pst = con.prepare(include_str!( + "../assets/queries/tags/get_all_tags_for_folder.sql" + ))?; + let rows = pst.query_map(rusqlite::params![folder_id], tagged_item_mapper)?; + rows.collect::, rusqlite::Error>>() +} + +/// Retrieves all tags for a folder with the passed id and tag type +/// +/// ## Parameters +/// - `folder_id`: the id of the folder to get tags for +/// - `tag_type`: the type of tags to retrieve. If [`TagTypes::Explicit`] is passed, only tags explicitly passed on the folder are returned. +/// If [`TagTypes::Implicit`] is passed, only implicated tags from parent folders are returned. +/// - `con`: a database connection to the database. Must be closed by the caller +/// +/// See Also: [`get_all_tags_for_folder`] to get all tags regardless of type +pub fn get_tags_for_folder( + folder_id: u32, + tag_type: TagTypes, + con: &Connection, +) -> Result, rusqlite::Error> { + let query = match tag_type { + TagTypes::Explicit => { + include_str!("../assets/queries/tags/get_explicit_tags_for_folder.sql") + } + TagTypes::_Implicit => { + include_str!("../assets/queries/tags/get_implicit_tags_for_folder.sql") + } + }; + let mut pst = con.prepare(query)?; + let rows = pst.query_map(rusqlite::params![folder_id], tagged_item_mapper)?; + rows.collect::, _>>() +} + +pub fn remove_explicit_tag_from_folder( + folder_id: u32, + tag_id: u32, + con: &Connection, +) -> Result<(), rusqlite::Error> { + let mut pst = con.prepare(include_str!( + "../assets/queries/tags/remove_explicit_tag_from_folder.sql" + ))?; + pst.execute(rusqlite::params![folder_id, tag_id])?; + Ok(()) +} + +// ================= both ================= + +/// for a given folder id, removes all implicit tags from descendants, so long as the tags being removed shouldn't be implied for the folder. +/// +/// For example, if a folder has tags A, B, and C; all files and folders that have tags implicated by `implied_from_id` are removed _unless_ they are A, B, or C. +/// This can be used to clean up tags that used to be implied by the folder, but no longer are. +/// +/// ## Parameters: +/// - `implied_from_id`: the id of the folder whose descendants need to have old implicated tags removed +/// - `con`: a connection to the database. Must be closed by the caller +pub fn remove_stale_implicit_tags_from_descendants( + implied_from_id: u32, + con: &Connection, +) -> Result<(), rusqlite::Error> { + let mut pst = con.prepare(include_str!( + "../assets/queries/tags/remove_stale_implicit_tags_from_descendants.sql" + ))?; + pst.execute([implied_from_id]).and(Ok(())) +} + +/// Batch removes all tags implicated on the passed files and folders via the passed `implicit_from_ids` +/// +/// This should be used when there are many files and folders being updated at once, such as when a folder is moved, and allows us to make 1 call to the +/// database engine instead of multiple +/// +/// ## Parameters: +/// - `file_ids`: the ids of the files to remove implicit tags from +/// - `folder_ids`: the ids of the folders to remove implicit tags from +/// - `implicit_from_ids`: the ids of the folders that implicate the tags to be removed +/// - `con`: a connection to the database. Must be closed by the caller +/// +/// ## Returns: +/// - `Ok(())` if the update completed successfully _or_ if `implicit_from_ids` is empty _or_ if both `file_ids` and `folder_ids` are empty +/// - `Err(rusqlite::Error)` if the database operation errors +pub fn batch_remove_implicit_tags( + file_ids: &[u32], + folder_ids: &[u32], + implicit_from_ids: &[u32], + con: &Connection, +) -> Result<(), rusqlite::Error> { + // to prevent the mass deletion of explicit tags or unnecessary work + if implicit_from_ids.is_empty() || (file_ids.is_empty() && folder_ids.is_empty()) { + return Ok(()); + } + + // chunk implicit_from_ids to prevent exceeding SQLite's limits + let implicit_id_chunks = implicit_from_ids.iter().chunks(999); + + for chunk in &implicit_id_chunks { + let implicit_clause = chunk.map(|id| id.to_string()).join(","); + + // build WHERE clause with IN conditions for files and folders + let mut where_parts = Vec::new(); + + if !file_ids.is_empty() { + let file_chunks = file_ids.chunks(999); + for file_chunk in file_chunks { + let file_clause = file_chunk.iter().map(|id| id.to_string()).join(","); + where_parts.push(format!("fileId in ({file_clause})")); + } + } + + if !folder_ids.is_empty() { + let folder_chunks = folder_ids.chunks(999); + for folder_chunk in folder_chunks { + let folder_clause = folder_chunk.iter().map(|id| id.to_string()).join(","); + where_parts.push(format!("folderId in ({folder_clause})")); + } + } + + let where_clause = where_parts.join(" or "); + let sql = format!( + "delete from TaggedItems where ({where_clause}) and implicitFromId in ({implicit_clause})" + ); + + log::debug!("batch_remove_implicit_tags sql: {sql}"); + con.execute(&sql, [])?; + } + + Ok(()) +} + +// ================= misc ================= +/// 1. id +/// 2. fileId +/// 3. folderId +/// 4. implicitFromId +/// 5. tagId +/// 6. title +fn tagged_item_mapper(row: &rusqlite::Row) -> Result { + let id: u32 = row.get(0)?; + let file_id: Option = row.get(1)?; + let folder_id: Option = row.get(2)?; + let implicit_from_id: Option = row.get(3)?; + let tag_id: u32 = row.get(4)?; + let title: String = row.get(5)?; + + Ok(models::TaggedItem { + id, + file_id, + folder_id, + implicit_from_id, + tag_id, + title, + }) +} + +/// maps a [`models::Tag`] from a database row +fn tag_mapper(row: &rusqlite::Row) -> Result { + let id: u32 = row.get(0)?; + let title: String = row.get(1)?; + Ok(models::Tag { id, title }) +} diff --git a/src/tags/service.rs b/src/tags/service.rs new file mode 100644 index 0000000..9f3d27e --- /dev/null +++ b/src/tags/service.rs @@ -0,0 +1,618 @@ +use std::backtrace::Backtrace; +use std::collections::HashSet; + +use itertools::Itertools; + +use super::{Tag, TagTypes}; +use crate::model::error::file_errors::GetFileError; +use crate::model::error::tag_errors::{ + CreateTagError, DeleteTagError, GetTagError, TagRelationError, UpdateTagError, +}; +use crate::model::response::{TagApi, TaggedItemApi}; +use crate::repository::{file_repository, folder_repository, open_connection}; +use crate::service::{file_service, folder_service}; +use crate::tags::repository; +use crate::tags::repository as tag_repository; + +/// will create a tag, or return the already-existing tag if one with the same name exists +/// returns the created/existing tag +pub fn create_tag(name: String) -> Result { + let con = open_connection(); + let existing_tag: Option = match tag_repository::get_tag_by_title(&name, &con) { + Ok(tags) => tags, + Err(e) => { + log::error!( + "Failed to check if any tags with the name {name} already exist! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(CreateTagError::DbError); + } + }; + let tag: Tag = if let Some(t) = existing_tag { + t + } else { + match tag_repository::create_tag(&name, &con) { + Ok(t) => t, + Err(e) => { + log::error!( + "Failed to create a new tag with the name {name}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(CreateTagError::DbError); + } + } + }; + + con.close().unwrap(); + Ok(TagApi::from(tag)) +} + +/// will return the tag with the passed id +pub fn get_tag(id: u32) -> Result { + let con = open_connection(); + let tag: Tag = match tag_repository::get_tag(id, &con) { + Ok(t) => t, + Err(rusqlite::Error::QueryReturnedNoRows) => { + log::error!( + "No tag with id {id} exists!\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(GetTagError::TagNotFound); + } + Err(e) => { + log::error!( + "Could not retrieve tag with id {id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(GetTagError::DbError); + } + }; + con.close().unwrap(); + Ok(TagApi::from(tag)) +} + +/// updates the tag with the passed id to the passed name. +/// Will fail if a tag already exists with that name +pub fn update_tag(request: TagApi) -> Result { + let con: rusqlite::Connection = open_connection(); + // make sure the tag exists first TODO cleanup - use if let Err pattern since Ok branch is empty + match tag_repository::get_tag(request.id.unwrap(), &con) { + Ok(_) => { /* no op */ } + Err(rusqlite::Error::QueryReturnedNoRows) => { + log::error!( + "Could not update tag with id {:?}, because it does not exist!\n{}", + request.id, + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(UpdateTagError::TagNotFound); + } + Err(e) => { + log::error!( + "Could not update tag with id {:?}! Error is {e}\n{}", + request.id, + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(UpdateTagError::DbError); + } + }; + let new_title = request.title; + // now make sure the database doesn't already have a tag with the new name TODO maybe see if can clean up, 2 empty branches is a smell + match tag_repository::get_tag_by_title(&new_title, &con) { + Ok(Some(_)) => { + log::error!( + "Could not update tag with id {:?} to name {new_title}, because a tag with that name already exists!\n{}", + request.id, + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(UpdateTagError::NewNameAlreadyExists); + } + Ok(None) => {} + Err(rusqlite::Error::QueryReturnedNoRows) => { /* this is the good route - no op */ } + Err(e) => { + log::error!( + "Could not search tags by name with value {new_title}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(UpdateTagError::DbError); + } + }; + // no match, and tag already exists so we're good to go + let db_tag = Tag { + id: request.id.unwrap(), + title: new_title.clone(), + }; + match tag_repository::update_tag(db_tag, &con) { + Ok(()) => {} + Err(e) => { + log::error!( + "Could not update tag with id {:?}! Error is {e}\n{}", + request.id, + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(UpdateTagError::DbError); + } + }; + con.close().unwrap(); + Ok(TagApi { + id: request.id, + title: new_title, + }) +} + +/// deletes the tag with the passed id. Does nothing if that tag doesn't exist +pub fn delete_tag(id: u32) -> Result<(), DeleteTagError> { + let con: rusqlite::Connection = open_connection(); + // TODO change to if let Err pattern, Ok branch is empty + match tag_repository::delete_tag(id, &con) { + Ok(()) => {} + Err(e) => { + log::error!( + "Could not delete tag with id {id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(DeleteTagError::DbError); + } + }; + con.close().unwrap(); + Ok(()) +} + +/// Updates the tags on a file by replacing all existing explicit tags with the provided list. +/// +/// Only explicit tags can be managed this way. After updating explicit tags, this function +/// will recalculate all implied tags from the file's ancestor folders. +/// +/// This function will: +/// 1. Remove all existing explicit tags from the file +/// 2. Add tags that already exist in the database (those with an `id`) +/// 3. Create and add new tags (those without an `id`) +/// 4. Recalculate and imply all ancestor tags to the file +/// +/// Duplicate tags in the input list will be automatically deduplicated to prevent +/// database constraint violations. +/// +/// # Parameters +/// - `file_id`: The ID of the file to update tags for +/// - `tags`: A vector of tags to set on the file. Tags with an `id` will be linked directly, +/// tags without an `id` will be created first (or retrieved if they already exist by name) +/// +/// # Returns +/// - `Ok(())` if the tags were successfully updated +/// - `Err(TagRelationError::FileNotFound)` if the file does not exist +/// - `Err(TagRelationError::DbError)` if there was a database error +pub fn update_file_tags(file_id: u32, tags: Vec) -> Result<(), TagRelationError> { + log::debug!("tags for file being updated: {tags:?}"); + // make sure the file exists + if Err(GetFileError::NotFound) == file_service::get_file_metadata(file_id) { + log::error!( + "Cannot update tag for file {file_id}, because that file does not exist!\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::FileNotFound); + } + let con = open_connection(); + // instead of removing all the tags and then adding them back, we can use a HashSet or 2 to enforce a unique list in-memory without as much IO + let existing_tags: HashSet = HashSet::from_iter(get_tags_on_file(file_id)?); + let tags = HashSet::from_iter(tags); + // we need to find 2 things: 1) tags to add 2) tags to remove + let tags_to_remove = existing_tags.difference(&tags); + let tags_to_add = tags.difference(&existing_tags); + for tag in tags_to_remove { + // tags from the db will always have a non-None tag id + if let Err(e) = + tag_repository::remove_explicit_tag_from_file(file_id, tag.tag_id.unwrap(), &con) + { + log::error!( + "Failed to remove tags from file with id {file_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(TagRelationError::DbError); + } + } + for tag in tags_to_add { + let created = match create_tag(tag.title.clone()) { + Ok(t) => t, + Err(e) => { + con.close().unwrap(); + log::error!( + "Failed to create tag! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + }; + if let Err(e) = tag_repository::add_explicit_tag_to_file(file_id, created.id.unwrap(), &con) + { + con.close().unwrap(); + log::error!( + "Failed to add tag to file: {e:?}\n{}", + Backtrace::force_capture(), + ); + return Err(TagRelationError::DbError); + } + } + con.close().unwrap(); + + // Recalculate implied tags from ancestors + imply_all_ancestor_tags(file_id)?; + + Ok(()) +} + +/// Updates the tags on a folder by replacing all existing tags with the provided list. +/// +/// The tags updated via this function must be explicit tags for that folder. +/// +/// This function will: +/// 1. Remove all existing tags from the folder +/// 2. Add tags that already exist in the database (those with an `id`) +/// 3. Create and add new tags (those without an `id`) +/// +/// Duplicate tags in the input list will be automatically deduplicated to prevent +/// database constraint violations. +/// +/// # Parameters +/// - `folder_id`: The ID of the folder to update tags for +/// - `tags`: A vector of tags to set on the folder. Tags with an `id` will be linked directly, +/// tags without an `id` will be created first (or retrieved if they already exist by name). +/// These tags must be explicit! no checking is done within the function +/// +/// # Returns +/// - `Ok(())` if the tags were successfully updated +/// - `Err(TagRelationError::FolderNotFound)` if the folder does not exist +/// - `Err(TagRelationError::DbError)` if there was a database error +pub fn update_folder_tags( + folder_id: u32, + tags: Vec, +) -> Result<(), TagRelationError> { + // make sure the file exists + if !folder_service::folder_exists(Some(folder_id)) { + log::error!("Cannot update tags for a folder that does not exist (id {folder_id}!"); + return Err(TagRelationError::FolderNotFound); + } + let existing_tags = get_tags_on_folder(folder_id)?; + let con: rusqlite::Connection = open_connection(); + // Remove all existing tags from the folder + for tag in existing_tags.iter() { + // tags from the db will always have a non-None tag id + if let Err(e) = + tag_repository::remove_explicit_tag_from_folder(folder_id, tag.tag_id.unwrap(), &con) + { + log::error!( + "Failed to remove tags from folder with id {folder_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(TagRelationError::DbError); + } + } + + // Track which tag IDs have been added to avoid duplicates + let mut added_tag_ids: HashSet = HashSet::new(); + + // First, add all existing tags (those with an id) + let existing_tags: Vec<&TaggedItemApi> = tags.iter().filter(|t| t.tag_id.is_some()).collect(); + for tag in existing_tags { + let tag_id = tag.tag_id.unwrap(); + // Skip if we've already added this tag + if added_tag_ids.contains(&tag_id) { + continue; + } + if let Err(e) = tag_repository::add_explicit_tag_to_folder(folder_id, tag_id, &con) { + log::error!( + "Failed to add tags to folder with id {folder_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(TagRelationError::DbError); + } + added_tag_ids.insert(tag_id); + } + + // Then, create and add new tags (those without an id) + let new_tags: Vec<&TaggedItemApi> = tags.iter().filter(|t| t.tag_id.is_none()).collect(); + for tag in new_tags { + let created_tag = match create_tag(tag.title.clone()) { + Ok(t) => t, + Err(e) => { + log::error!( + "Failed to create tag! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(TagRelationError::DbError); + } + }; + let tag_id = created_tag.id.unwrap(); + // Skip if we've already added this tag (prevents duplicates) + if added_tag_ids.contains(&tag_id) { + continue; + } + if let Err(e) = tag_repository::add_explicit_tag_to_folder(folder_id, tag_id, &con) { + log::error!( + "Failed to add tags to folder with id {folder_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(TagRelationError::DbError); + } + added_tag_ids.insert(tag_id); + } + + con.close().unwrap(); + + // Propagate tag changes to all descendants + pass_tags_to_descendants(folder_id)?; + + Ok(()) +} + +/// retrieves all the tags on the file with the passed id +pub fn get_tags_on_file(file_id: u32) -> Result, TagRelationError> { + // make sure the file exists + if !file_service::check_file_exists(file_id) { + log::error!( + "Cannot get tags on file with id {file_id}, because that file does not exist!\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::FileNotFound); + } + let con: rusqlite::Connection = open_connection(); + let file_tags = match tag_repository::get_all_tags_for_file(file_id, &con) { + Ok(tags) => tags, + Err(e) => { + log::error!( + "Failed to retrieve tags on file with id {file_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(TagRelationError::DbError); + } + }; + con.close().unwrap(); + Ok(file_tags.into_iter().map_into().collect()) +} + +/// retrieves all the tags on the folder with the passed id. +/// This will always be empty if requesting with the root folder id (0 or None) +pub fn get_tags_on_folder(folder_id: u32) -> Result, TagRelationError> { + // make sure the folder exists + if !folder_service::folder_exists(Some(folder_id)) { + log::error!( + "Cannot get tags on folder with id {folder_id}, because that folder does not exist!\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::FileNotFound); + } + let con: rusqlite::Connection = open_connection(); + let db_tags = match tag_repository::get_all_tags_for_folder(folder_id, &con) { + Ok(tags) => tags, + Err(e) => { + log::error!( + "Failed to retrieve tags on folder with id {folder_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(TagRelationError::DbError); + } + }; + con.close().unwrap(); + Ok(db_tags.into_iter().map(TaggedItemApi::from).collect()) +} + +/// Propagates tag changes from a folder to all its descendant files and folders. +/// +/// This function ensures that: +/// - All explicit tags on the folder are implied to descendants (if not already present) +/// - All removed explicit tags have their implications removed from descendants +/// - Explicit tags on descendants are never overridden +/// +/// ## Parameters +/// - `folder_id`: the id of the folder whose tags should be propagated to descendants +/// +/// ## Returns +/// - `Ok(())` if tags were successfully propagated +/// - `Err(TagRelationError)` if there was a database error or the folder doesn't exist +pub fn pass_tags_to_descendants(folder_id: u32) -> Result<(), TagRelationError> { + // Verify folder exists + if !folder_service::folder_exists(Some(folder_id)) { + log::error!( + "Cannot pass tags to children of folder {folder_id} because it does not exist!\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::FolderNotFound); + } + + let con = open_connection(); + + // Get all descendant folders, which doubles as a way to get all descendant files later + let mut all_folder_ids = match folder_repository::get_all_child_folder_ids(&[folder_id], &con) { + Ok(folders) => folders, + Err(e) => { + log::error!( + "Failed to retrieve descendant folders for folder {folder_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(TagRelationError::DbError); + } + }; + // need to add the original folder id so that it's truly all folder ids involved + all_folder_ids.push(folder_id); + let descendant_files: Vec = match folder_repository::get_child_files(&all_folder_ids, &con) + { + Ok(files) => files.into_iter().map(|f| f.id.unwrap()).collect(), + Err(e) => { + log::error!( + "Failed to retrieve descendant files for folder {folder_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(TagRelationError::DbError); + } + }; + + // now that we have all descendant folders and files, we need to remove all implicated tags that shouldn't be there + if let Err(e) = repository::remove_stale_implicit_tags_from_descendants(folder_id, &con) { + con.close().unwrap(); + log::error!( + "Failed to remove implicit tags from descendants of folder {folder_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + /* stale implied tags are removed, affected files and folders now need to be updated to re-inherit from folders that have that tag. + This is because a higher parent could have received that tag after `folder_id` got it. It shouldn't be that a child folder having its tags changed should cause this, + because adding a tag to a folder should be blocked if a parent has that tag.*/ + let current_ancestor_ids = match folder_repository::get_ancestor_folders_with_id( + folder_id, &con, + ) { + Ok(ids) => ids, + Err(e) => { + con.close().unwrap(); + log::error!( + "Failed to retrieve ancestor folders for folder {folder_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + }; + // current ancestor ids is in correct order, but we need to insert the current folder id in the beginning as it's the closest to the files and folders being altered + let mut all_ancestor_ids = Vec::with_capacity(1 + current_ancestor_ids.len()); + all_ancestor_ids.insert(0, folder_id); + all_ancestor_ids.extend(current_ancestor_ids); + for ancestor in all_ancestor_ids { + let ancestor_tags = match repository::get_tags_for_folder( + ancestor, + TagTypes::Explicit, + &con, + ) { + Ok(t) => t, + Err(e) => { + con.close().unwrap(); + log::error!( + "Failed to retrieve tags for ancestor folder {ancestor}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + }; + // since we're in depth-first order, we can flatly call the imply tag function since it will ignore if a record already exists + for ancestor_tag in ancestor_tags { + if let Err(e) = repository::add_implicit_tag_to_files( + ancestor_tag.tag_id, + &descendant_files, + ancestor, + &con, + ) { + con.close().unwrap(); + log::error!( + "Failed to add implicit tag {ancestor_tag:?} to descendant files of folder {folder_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + // we use all_folder_ids here because the folder being updated needs to inherit from parents too if an explicit tag was removed + if let Err(e) = repository::add_implicit_tag_to_folders( + ancestor_tag.tag_id, + &all_folder_ids, + ancestor, + &con, + ) { + con.close().unwrap(); + log::error!( + "Failed to add implicit tag {ancestor_tag:?} to descendant folders of folder {folder_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + } + } + con.close().unwrap(); + Ok(()) +} + +/// Implies all explicit tags from a file's ancestor folders to the file. +/// +/// This function retrieves all ancestor folders of a file and implies their +/// explicit tags to the file. Tags are processed in depth-first order (closest +/// ancestor first), ensuring that tags from closer ancestors take precedence. +/// +/// ## Parameters +/// - `file_id`: the ID of the file to imply ancestor tags to +/// +/// ## Returns +/// - `Ok(())` if tags were successfully implied +/// - `Err(TagRelationError::FileNotFound)` if the file doesn't exist +/// - `Err(TagRelationError::DbError)` if there was a database error +pub fn imply_all_ancestor_tags(file_id: u32) -> Result<(), TagRelationError> { + // Verify file exists + if !file_service::check_file_exists(file_id) { + log::error!( + "Cannot imply ancestor tags to file {file_id} because it does not exist!\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::FileNotFound); + } + + let con = open_connection(); + + // Get all ancestors in depth-first order + let ancestor_ids = match file_repository::get_all_ancestors(file_id, &con) { + Ok(ids) => ids, + Err(e) => { + log::error!( + "Failed to retrieve ancestors for file {file_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(TagRelationError::DbError); + } + }; + + // For each ancestor (in depth-first order), get explicit tags and imply them + for ancestor_id in ancestor_ids { + let ancestor_tags = match repository::get_tags_for_folder( + ancestor_id, + TagTypes::Explicit, + &con, + ) { + Ok(t) => t, + Err(e) => { + con.close().unwrap(); + log::error!( + "Failed to retrieve tags for ancestor folder {ancestor_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + }; + + // Imply ancestor's tags to the file + let tag_ids: Vec = ancestor_tags.iter().map(|t| t.tag_id).collect(); + if let Err(e) = + repository::add_implicit_tags_to_files(&[file_id], &tag_ids, ancestor_id, &con) + { + con.close().unwrap(); + log::error!( + "Failed to add implicit tags to file {file_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + } + + con.close().unwrap(); + Ok(()) +} diff --git a/src/tags/tests/handler.rs b/src/tags/tests/handler.rs new file mode 100644 index 0000000..58c32da --- /dev/null +++ b/src/tags/tests/handler.rs @@ -0,0 +1,153 @@ +use rocket::http::{Header, Status}; + +use crate::repository::initialize_db; +use crate::test::*; + +mod get_tag_tests { + use super::*; + + #[test] + fn without_creds() { + initialize_db().unwrap(); + let client = client(); + let res = client.get(uri!("/tags/1")).dispatch(); + assert_eq!(res.status(), Status::Unauthorized); + cleanup(); + } + + #[test] + fn success() { + set_password(); + create_tag_db_entry("test_tag"); + let client = client(); + let auth = Header::new("Authorization", AUTH); + let res = client.get(uri!("/tags/1")).header(auth).dispatch(); + assert_eq!(res.status(), Status::Ok); + cleanup(); + } + + #[test] + fn not_found() { + set_password(); + let client = client(); + let auth = Header::new("Authorization", AUTH); + let res = client.get(uri!("/tags/999")).header(auth).dispatch(); + assert_eq!(res.status(), Status::NotFound); + cleanup(); + } +} + +mod create_tag_tests { + use super::*; + + #[test] + fn without_creds() { + initialize_db().unwrap(); + let client = client(); + let res = client + .post(uri!("/tags")) + .body(r#"{"title":"new_tag"}"#) + .dispatch(); + assert_eq!(res.status(), Status::Unauthorized); + cleanup(); + } + + #[test] + fn success() { + set_password(); + let client = client(); + let auth = Header::new("Authorization", AUTH); + let res = client + .post(uri!("/tags")) + .header(auth) + .body(r#"{"title":"new_tag"}"#) + .dispatch(); + assert_eq!(res.status(), Status::Created); + cleanup(); + } +} + +mod update_tag_tests { + use super::*; + + #[test] + fn without_creds() { + initialize_db().unwrap(); + let client = client(); + let res = client + .put(uri!("/tags")) + .body(r#"{"id":1,"title":"updated_tag"}"#) + .dispatch(); + assert_eq!(res.status(), Status::Unauthorized); + cleanup(); + } + + #[test] + fn success() { + set_password(); + create_tag_db_entry("original_tag"); + let client = client(); + let auth = Header::new("Authorization", AUTH); + let res = client + .put(uri!("/tags")) + .header(auth) + .body(r#"{"id":1,"title":"updated_tag"}"#) + .dispatch(); + assert_eq!(res.status(), Status::Ok); + cleanup(); + } + + #[test] + fn not_found() { + set_password(); + let client = client(); + let auth = Header::new("Authorization", AUTH); + let res = client + .put(uri!("/tags")) + .header(auth) + .body(r#"{"id":999,"title":"updated_tag"}"#) + .dispatch(); + assert_eq!(res.status(), Status::NotFound); + cleanup(); + } + + #[test] + fn already_exists() { + set_password(); + create_tag_db_entry("tag1"); + create_tag_db_entry("tag2"); + let client = client(); + let auth = Header::new("Authorization", AUTH); + let res = client + .put(uri!("/tags")) + .header(auth) + .body(r#"{"id":2,"title":"tag1"}"#) + .dispatch(); + assert_eq!(res.status(), Status::BadRequest); + cleanup(); + } +} + +mod delete_tag_tests { + use super::*; + + #[test] + fn without_creds() { + initialize_db().unwrap(); + let client = client(); + let res = client.delete(uri!("/tags/1")).dispatch(); + assert_eq!(res.status(), Status::Unauthorized); + cleanup(); + } + + #[test] + fn success() { + set_password(); + create_tag_db_entry("test_tag"); + let client = client(); + let auth = Header::new("Authorization", AUTH); + let res = client.delete(uri!("/tags/1")).header(auth).dispatch(); + assert_eq!(res.status(), Status::NoContent); + cleanup(); + } +} diff --git a/src/tags/tests/mod.rs b/src/tags/tests/mod.rs new file mode 100644 index 0000000..c8c9368 --- /dev/null +++ b/src/tags/tests/mod.rs @@ -0,0 +1,3 @@ +mod handler; +mod repository; +mod service; diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs new file mode 100644 index 0000000..05fa7eb --- /dev/null +++ b/src/tags/tests/repository.rs @@ -0,0 +1,735 @@ +mod create_tag_tests { + use crate::repository::open_connection; + use crate::tags::Tag; + use crate::tags::repository; + use crate::test::{cleanup, init_db_folder}; + + #[test] + fn create_tag() { + init_db_folder(); + let con = open_connection(); + let tag = repository::create_tag("test", &con).unwrap(); + con.close().unwrap(); + assert_eq!( + Tag { + id: 1, + title: "test".to_string(), + }, + tag + ); + cleanup(); + } +} + +mod get_tag_by_title_tests { + use crate::repository::open_connection; + use crate::tags::Tag; + use crate::tags::repository::{create_tag, get_tag_by_title}; + use crate::test::*; + + #[test] + fn get_tag_by_title_found() { + init_db_folder(); + let con = open_connection(); + create_tag("test", &con).unwrap(); + let found = get_tag_by_title("TeSt", &con).unwrap(); + con.close().unwrap(); + assert_eq!( + Some(Tag { + id: 1, + title: "test".to_string(), + }), + found + ); + cleanup(); + } + #[test] + fn get_tag_by_title_not_found() { + init_db_folder(); + let con = open_connection(); + let not_found = get_tag_by_title("test", &con).unwrap(); + con.close().unwrap(); + assert_eq!(None, not_found); + cleanup(); + } +} + +mod get_tag_by_id_tests { + use crate::repository::open_connection; + use crate::tags::Tag; + use crate::tags::repository::{create_tag, get_tag}; + use crate::test::{cleanup, init_db_folder}; + + #[test] + fn get_tag_success() { + init_db_folder(); + let con = open_connection(); + create_tag("test", &con).unwrap(); + let tag = get_tag(1, &con).unwrap(); + con.close().unwrap(); + assert_eq!( + Tag { + id: 1, + title: "test".to_string(), + }, + tag + ); + cleanup(); + } +} + +mod update_tag_tests { + use crate::repository::open_connection; + use crate::tags::Tag; + use crate::tags::repository::{create_tag, get_tag, update_tag}; + use crate::test::{cleanup, init_db_folder}; + + #[test] + fn update_tag_success() { + init_db_folder(); + let con = open_connection(); + create_tag("test", &con).unwrap(); + update_tag( + Tag { + id: 1, + title: "test2".to_string(), + }, + &con, + ) + .unwrap(); + let res = get_tag(1, &con).unwrap(); + con.close().unwrap(); + assert_eq!( + Tag { + id: 1, + title: "test2".to_string(), + }, + res + ); + cleanup(); + } +} + +mod delete_tag_tests { + use crate::repository::open_connection; + use crate::tags::repository::{create_tag, delete_tag, get_tag}; + use crate::test::{cleanup, init_db_folder}; + + #[test] + fn delete_tag_success() { + init_db_folder(); + let con = open_connection(); + create_tag("test", &con).unwrap(); + delete_tag(1, &con).unwrap(); + let not_found = get_tag(1, &con); + con.close().unwrap(); + assert_eq!(Err(rusqlite::Error::QueryReturnedNoRows), not_found); + cleanup(); + } +} + +mod get_tag_on_file_tests { + use crate::model::file_types::FileTypes; + use crate::model::repository::FileRecord; + use crate::repository::file_repository::create_file; + use crate::repository::open_connection; + use crate::tags::TaggedItem; + use crate::tags::repository::*; + use crate::test::*; + + #[test] + fn get_tags_on_file_returns_tags() { + init_db_folder(); + let con = open_connection(); + create_tag("test", &con).unwrap(); + create_tag("test2", &con).unwrap(); + create_file( + &FileRecord { + id: None, + name: "test_file".to_string(), + parent_id: None, + create_date: now(), + size: 0, + file_type: FileTypes::Unknown, + }, + &con, + ) + .unwrap(); + add_explicit_tag_to_file(1, 1, &con).unwrap(); + add_explicit_tag_to_file(1, 2, &con).unwrap(); + let res = get_all_tags_for_file(1, &con).unwrap(); + con.close().unwrap(); + assert_eq!( + vec![ + TaggedItem { + id: 1, + tag_id: 1, + title: "test".to_string(), + file_id: Some(1), + folder_id: None, + implicit_from_id: None + }, + TaggedItem { + id: 2, + tag_id: 2, + title: "test2".to_string(), + file_id: Some(1), + folder_id: None, + implicit_from_id: None + } + ], + res + ); + cleanup(); + } + #[test] + fn get_tags_on_file_returns_nothing_if_no_tags() { + init_db_folder(); + let con = open_connection(); + create_file( + &FileRecord { + id: None, + name: "test_file".to_string(), + parent_id: None, + create_date: now(), + size: 0, + file_type: FileTypes::Application, + }, + &con, + ) + .unwrap(); + let res = get_all_tags_for_file(1, &con).unwrap(); + con.close().unwrap(); + assert_eq!(Vec::::new(), res); + cleanup(); + } +} + +mod remove_tag_from_file_tests { + use crate::model::file_types::FileTypes; + use crate::model::repository::FileRecord; + use crate::repository::file_repository::create_file; + use crate::repository::open_connection; + use crate::tags::TaggedItem; + use crate::tags::repository::*; + use crate::test::{cleanup, init_db_folder, now}; + + #[test] + fn remove_tag_from_file_works() { + init_db_folder(); + let con = open_connection(); + create_tag("test", &con).unwrap(); + create_file( + &FileRecord { + id: None, + name: "test_file".to_string(), + parent_id: None, + create_date: now(), + size: 0, + file_type: FileTypes::Unknown, + }, + &con, + ) + .unwrap(); + remove_explicit_tag_from_file(1, 1, &con).unwrap(); + let tags = get_all_tags_for_file(1, &con).unwrap(); + con.close().unwrap(); + assert_eq!(Vec::::new(), tags); + cleanup(); + } +} + +mod get_tag_on_folder_tests { + use crate::model::repository::Folder; + use crate::repository::folder_repository::create_folder; + use crate::repository::open_connection; + use crate::tags::TaggedItem; + use crate::tags::repository::{ + add_explicit_tag_to_folder, create_tag, get_all_tags_for_folder, + }; + use crate::test::*; + + #[test] + fn get_tags_on_folder_returns_tags() { + init_db_folder(); + let con = open_connection(); + create_tag("test", &con).unwrap(); + create_tag("test2", &con).unwrap(); + create_folder( + &Folder { + parent_id: None, + id: None, + name: "test_folder".to_string(), + }, + &con, + ) + .unwrap(); + add_explicit_tag_to_folder(1, 1, &con).unwrap(); + add_explicit_tag_to_folder(1, 2, &con).unwrap(); + let res = get_all_tags_for_folder(1, &con).unwrap(); + con.close().unwrap(); + assert_eq!( + vec![ + TaggedItem { + id: 1, + tag_id: 1, + title: "test".to_string(), + folder_id: Some(1), + file_id: None, + implicit_from_id: None + }, + TaggedItem { + id: 2, + tag_id: 2, + title: "test2".to_string(), + folder_id: Some(1), + file_id: None, + implicit_from_id: None + } + ], + res + ); + cleanup(); + } + #[test] + fn get_tags_on_folder_returns_nothing_if_no_tags() { + init_db_folder(); + let con = open_connection(); + create_folder( + &Folder { + parent_id: None, + id: None, + name: "test_folder".to_string(), + }, + &con, + ) + .unwrap(); + let res = get_all_tags_for_folder(1, &con).unwrap(); + con.close().unwrap(); + assert_eq!(Vec::::new(), res); + cleanup(); + } +} + +mod remove_tag_from_folder_tests { + use crate::model::repository::Folder; + use crate::repository::folder_repository::create_folder; + use crate::repository::open_connection; + use crate::tags::TaggedItem; + use crate::tags::repository::{ + create_tag, get_all_tags_for_folder, remove_explicit_tag_from_folder, + }; + use crate::test::{cleanup, init_db_folder}; + + #[test] + fn remove_tag_from_folder_works() { + init_db_folder(); + let con = open_connection(); + create_tag("test", &con).unwrap(); + create_folder( + &Folder { + parent_id: None, + id: None, + name: "test_folder".to_string(), + }, + &con, + ) + .unwrap(); + remove_explicit_tag_from_folder(1, 1, &con).unwrap(); + let tags = get_all_tags_for_folder(1, &con).unwrap(); + con.close().unwrap(); + assert_eq!(Vec::::new(), tags); + cleanup(); + } +} + +mod get_tags_on_files_tests { + use std::collections::HashMap; + + use crate::tags::TaggedItem; + use crate::tags::repository::get_all_tags_for_files; + use crate::{repository::open_connection, test::*}; + + #[test] + fn returns_proper_mapping_for_file_tags() { + init_db_folder(); + create_file_db_entry("file1", None); + create_file_db_entry("file2", None); + create_file_db_entry("control", None); + create_tag_file("tag1", 1); + create_tag_file("tag2", 1); + create_tag_file("tag3", 2); + let con = open_connection(); + let res = get_all_tags_for_files(vec![1, 2, 3], &con).unwrap(); + con.close().unwrap(); + #[rustfmt::skip] + let expected = HashMap::from([ + (1, vec![ + TaggedItem {id: 1, tag_id: 1, file_id: Some(1), folder_id: None, title: "tag1".to_string(), implicit_from_id: None}, + TaggedItem {id: 2, tag_id: 2, file_id: Some(1), folder_id: None, title: "tag2".to_string(), implicit_from_id: None}, + ] + ), + (2, vec![TaggedItem {id: 3, tag_id: 3, file_id: Some(2), folder_id: None, title: "tag3".to_string(), implicit_from_id: None}]) + ]); + assert_eq!(res, expected); + cleanup(); + } +} + +mod add_implicit_tag_to_folders_tests { + use crate::repository::open_connection; + use crate::tags::repository::{add_implicit_tag_to_folders, get_all_tags_for_folder}; + use crate::test::*; + + #[test] + fn adds_tags_to_multiple_folders() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + create_folder_db_entry("child1", Some(1)); // id 2 + create_folder_db_entry("child2", Some(1)); // id 3 + let tag_id = create_tag_db_entry("test_tag"); + let con = open_connection(); + add_implicit_tag_to_folders(tag_id, &[2, 3], 1, &con).unwrap(); + let tags2 = get_all_tags_for_folder(2, &con).unwrap(); + let tags3 = get_all_tags_for_folder(3, &con).unwrap(); + assert_eq!(tags2.len(), 1); + assert_eq!(tags2[0].tag_id, tag_id); + assert_eq!(tags2[0].implicit_from_id, Some(1)); + assert_eq!(tags3.len(), 1); + assert_eq!(tags3[0].tag_id, tag_id); + assert_eq!(tags3[0].implicit_from_id, Some(1)); + con.close().unwrap(); + cleanup(); + } + + #[test] + fn works_with_empty_slice() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + let tag_id = create_tag_db_entry("test_tag"); + let con = open_connection(); + add_implicit_tag_to_folders(tag_id, &[], 1, &con).unwrap(); + con.close().unwrap(); + cleanup(); + } + + #[test] + fn works_with_single_folder() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + create_folder_db_entry("child", Some(1)); // id 2 + let tag_id = create_tag_db_entry("test_tag"); + let con = open_connection(); + add_implicit_tag_to_folders(tag_id, &[2], 1, &con).unwrap(); + let tags = get_all_tags_for_folder(2, &con).unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].tag_id, tag_id); + assert_eq!(tags[0].implicit_from_id, Some(1)); + con.close().unwrap(); + cleanup(); + } +} + +mod add_implicit_tag_to_files_tests { + use crate::repository::open_connection; + use crate::tags::repository::{add_implicit_tag_to_files, get_all_tags_for_file}; + use crate::test::*; + + #[test] + fn adds_tags_to_multiple_files() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + create_file_db_entry("file1.txt", Some(1)); // id 1 + create_file_db_entry("file2.txt", Some(1)); // id 2 + let tag_id = create_tag_db_entry("test_tag"); + let con = open_connection(); + add_implicit_tag_to_files(tag_id, &[1, 2], 1, &con).unwrap(); + let tags1 = get_all_tags_for_file(1, &con).unwrap(); + let tags2 = get_all_tags_for_file(2, &con).unwrap(); + assert_eq!(tags1.len(), 1); + assert_eq!(tags1[0].tag_id, tag_id); + assert_eq!(tags1[0].implicit_from_id, Some(1)); + assert_eq!(tags2.len(), 1); + assert_eq!(tags2[0].tag_id, tag_id); + assert_eq!(tags2[0].implicit_from_id, Some(1)); + con.close().unwrap(); + cleanup(); + } + + #[test] + fn works_with_empty_slice() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + let tag_id = create_tag_db_entry("test_tag"); + let con = open_connection(); + add_implicit_tag_to_files(tag_id, &[], 1, &con).unwrap(); + con.close().unwrap(); + cleanup(); + } + + #[test] + fn works_with_single_file() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + create_file_db_entry("file.txt", Some(1)); // id 1 + let tag_id = create_tag_db_entry("test_tag"); + let con = open_connection(); + add_implicit_tag_to_files(tag_id, &[1], 1, &con).unwrap(); + let tags = get_all_tags_for_file(1, &con).unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].tag_id, tag_id); + assert_eq!(tags[0].implicit_from_id, Some(1)); + con.close().unwrap(); + cleanup(); + } +} + +mod add_implicit_tags_to_files_tests { + use crate::repository::open_connection; + use crate::tags::repository::{add_implicit_tags_to_files, get_all_tags_for_file}; + use crate::test::*; + + #[test] + fn adds_multiple_tags_to_multiple_files() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + create_file_db_entry("file1.txt", Some(1)); // id 1 + create_file_db_entry("file2.txt", Some(1)); // id 2 + let tag_id1 = create_tag_db_entry("test_tag1"); + let tag_id2 = create_tag_db_entry("test_tag2"); + let con = open_connection(); + add_implicit_tags_to_files(&[1, 2], &[tag_id1, tag_id2], 1, &con).unwrap(); + let tags1 = get_all_tags_for_file(1, &con).unwrap(); + let tags2 = get_all_tags_for_file(2, &con).unwrap(); + assert_eq!(tags1.len(), 2); + assert_eq!(tags2.len(), 2); + con.close().unwrap(); + cleanup(); + } + + #[test] + fn does_nothing_when_file_ids_is_empty() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + create_file_db_entry("file.txt", Some(1)); // id 1 + let tag_id = create_tag_db_entry("test_tag"); + let con = open_connection(); + // Should not add anything when file_ids is empty + let result = add_implicit_tags_to_files(&[], &[tag_id], 1, &con); + assert!(result.is_ok()); + // Verify no tags were added to the file + let tags = get_all_tags_for_file(1, &con).unwrap(); + assert_eq!(tags.len(), 0, "No tags should have been added"); + con.close().unwrap(); + cleanup(); + } + + #[test] + fn does_nothing_when_tag_ids_is_empty() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + create_file_db_entry("file.txt", Some(1)); // id 1 + let con = open_connection(); + // Should not add anything when tag_ids is empty + let result = add_implicit_tags_to_files(&[1], &[], 1, &con); + assert!(result.is_ok()); + // Verify no tags were added + let tags = get_all_tags_for_file(1, &con).unwrap(); + assert_eq!(tags.len(), 0); + con.close().unwrap(); + cleanup(); + } + + #[test] + fn does_not_add_unspecified_tags() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + create_file_db_entry("file1.txt", Some(1)); // id 1 + create_file_db_entry("file2.txt", Some(1)); // id 2 + let tag_id1 = create_tag_db_entry("test_tag1"); + let _tag_id2 = create_tag_db_entry("test_tag2"); + let con = open_connection(); + // Only add tag1 to file1 + add_implicit_tags_to_files(&[1], &[tag_id1], 1, &con).unwrap(); + let tags1 = get_all_tags_for_file(1, &con).unwrap(); + let tags2 = get_all_tags_for_file(2, &con).unwrap(); + assert_eq!(tags1.len(), 1); + assert_eq!(tags1[0].tag_id, tag_id1); + assert_eq!(tags2.len(), 0); // file2 should have no tags + con.close().unwrap(); + cleanup(); + } + + #[test] + fn handles_large_numbers() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + // Create many files + for i in 1..=50 { + create_file_db_entry(&format!("file{i}.txt"), Some(1)); + } + // Create many tags + let mut tag_ids = Vec::new(); + for i in 1..=20 { + tag_ids.push(create_tag_db_entry(&format!("tag{i}"))); + } + let file_ids: Vec = (1..=50).collect(); + let con = open_connection(); + add_implicit_tags_to_files(&file_ids, &tag_ids, 1, &con).unwrap(); + // Verify all files have all tags + for file_id in &file_ids { + let tags = get_all_tags_for_file(*file_id, &con).unwrap(); + assert_eq!(tags.len(), 20); + } + con.close().unwrap(); + cleanup(); + } +} + +mod batch_remove_implicit_tags_tests { + use crate::repository::open_connection; + use crate::tags::repository::{ + batch_remove_implicit_tags, get_all_tags_for_file, get_all_tags_for_folder, + }; + use crate::test::*; + + #[test] + fn removes_all_specified_tags() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + create_file_db_entry("file.txt", Some(1)); // id 1 + let tag_id = create_tag_db_entry("test_tag"); + imply_tag_on_file(tag_id, 1, 1); + let con = open_connection(); + batch_remove_implicit_tags(&[1], &[], &[1], &con).unwrap(); + let tags = get_all_tags_for_file(1, &con).unwrap(); + assert_eq!(tags.len(), 0); + con.close().unwrap(); + cleanup(); + } + + #[test] + fn does_not_touch_unspecified_entries() { + init_db_folder(); + create_folder_db_entry("parent1", None); // id 1 + create_folder_db_entry("parent2", None); // id 2 + create_file_db_entry("file1.txt", Some(1)); // id 1 + create_file_db_entry("file2.txt", Some(2)); // id 2 + let tag_id = create_tag_db_entry("test_tag"); + imply_tag_on_file(tag_id, 1, 1); + imply_tag_on_file(tag_id, 2, 2); + let con = open_connection(); + // Remove only from file 1 + batch_remove_implicit_tags(&[1], &[], &[1], &con).unwrap(); + let tags1 = get_all_tags_for_file(1, &con).unwrap(); + let tags2 = get_all_tags_for_file(2, &con).unwrap(); + assert_eq!(tags1.len(), 0); + assert_eq!(tags2.len(), 1); // file 2 should still have its tag + con.close().unwrap(); + cleanup(); + } + + #[test] + fn handles_many_file_ids() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + create_file_db_entry("file1.txt", Some(1)); // id 1 + create_file_db_entry("file2.txt", Some(1)); // id 2 + let tag_id = create_tag_db_entry("test_tag"); + // Add implicit tags to file 1 and file 2 + imply_tag_on_file(tag_id, 1, 1); + imply_tag_on_file(tag_id, 2, 1); + let con = open_connection(); + // Generate a large range of file ids including 1 and 2 (but many won't exist) + let file_ids: Vec = (1..=5000).collect(); + batch_remove_implicit_tags(&file_ids, &[], &[1], &con).unwrap(); + let tags1 = get_all_tags_for_file(1, &con).unwrap(); + let tags2 = get_all_tags_for_file(2, &con).unwrap(); + assert_eq!(tags1.len(), 0); + assert_eq!(tags2.len(), 0); + con.close().unwrap(); + cleanup(); + } + + #[test] + fn handles_many_folder_ids() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + create_folder_db_entry("child1", Some(1)); // id 2 + create_folder_db_entry("child2", Some(1)); // id 3 + let tag_id = create_tag_db_entry("test_tag"); + // Add implicit tags to folder 2 and folder 3 + imply_tag_on_folder(tag_id, 2, 1); + imply_tag_on_folder(tag_id, 3, 1); + let con = open_connection(); + // Generate a large range of folder ids including 2 and 3 (but many won't exist) + let folder_ids: Vec = (1..=5000).collect(); + batch_remove_implicit_tags(&[], &folder_ids, &[1], &con).unwrap(); + let tags2 = get_all_tags_for_folder(2, &con).unwrap(); + let tags3 = get_all_tags_for_folder(3, &con).unwrap(); + assert_eq!(tags2.len(), 0); + assert_eq!(tags3.len(), 0); + con.close().unwrap(); + cleanup(); + } + + #[test] + fn handles_many_implicit_from_ids() { + init_db_folder(); + create_folder_db_entry("parent1", None); // id 1 + create_folder_db_entry("parent2", None); // id 2 + create_file_db_entry("file.txt", None); // id 1 + let tag_id1 = create_tag_db_entry("test_tag1"); + let tag_id2 = create_tag_db_entry("test_tag2"); + // Add implicit tags from folder 1 and folder 2 (different tags to avoid unique constraint) + imply_tag_on_file(tag_id1, 1, 1); + imply_tag_on_file(tag_id2, 1, 2); + let con = open_connection(); + // Verify we have 2 tags now + let tags_before = get_all_tags_for_file(1, &con).unwrap(); + assert_eq!(tags_before.len(), 2); + // Generate a large range of implicit_from_ids including 1 and 2 (but many won't exist) + let implicit_from_ids: Vec = (1..=5000).collect(); + batch_remove_implicit_tags(&[1], &[], &implicit_from_ids, &con).unwrap(); + let tags_after = get_all_tags_for_file(1, &con).unwrap(); + assert_eq!(tags_after.len(), 0); + con.close().unwrap(); + cleanup(); + } + + #[test] + fn does_nothing_when_implicit_from_ids_is_empty() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + create_file_db_entry("file.txt", Some(1)); // id 1 + let tag_id = create_tag_db_entry("test_tag"); + imply_tag_on_file(tag_id, 1, 1); + let con = open_connection(); + // Should not remove anything when implicit_from_ids is empty + let result = batch_remove_implicit_tags(&[1], &[1], &[], &con); + assert!(result.is_ok()); + // Verify the tag still exists + let tags = get_all_tags_for_file(1, &con).unwrap(); + assert_eq!(tags.len(), 1, "Tag should not have been removed"); + con.close().unwrap(); + cleanup(); + } + + #[test] + fn does_nothing_when_file_and_folder_ids_empty() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + create_file_db_entry("file.txt", Some(1)); // id 1 + let tag_id = create_tag_db_entry("test_tag"); + imply_tag_on_file(tag_id, 1, 1); + let con = open_connection(); + // Should not remove anything when both file_ids and folder_ids are empty + let result = batch_remove_implicit_tags(&[], &[], &[1], &con); + assert!(result.is_ok()); + // Verify the tag still exists + let tags = get_all_tags_for_file(1, &con).unwrap(); + assert_eq!(tags.len(), 1, "Tag should not have been removed"); + con.close().unwrap(); + cleanup(); + } +} diff --git a/src/tags/tests/service.rs b/src/tags/tests/service.rs new file mode 100644 index 0000000..11a7ae5 --- /dev/null +++ b/src/tags/tests/service.rs @@ -0,0 +1,988 @@ +mod get_tag_tests { + use crate::model::error::tag_errors::GetTagError; + use crate::tags::service::{create_tag, get_tag}; + use crate::test::*; + + #[test] + fn test_get_tag() { + init_db_folder(); + let expected = create_tag("test".to_string()).unwrap(); + let actual = get_tag(1).unwrap(); + assert_eq!(actual, expected); + cleanup(); + } + + #[test] + fn test_get_tag_non_existent() { + init_db_folder(); + let res = get_tag(1).expect_err("Retrieving a nonexistent tag should return an error"); + assert_eq!(GetTagError::TagNotFound, res); + cleanup(); + } +} + +mod update_tag_tests { + use crate::model::error::tag_errors::UpdateTagError; + use crate::model::response::TagApi; + use crate::tags::service::{create_tag, get_tag, update_tag}; + use crate::test::{cleanup, init_db_folder}; + + #[test] + fn update_tag_works() { + init_db_folder(); + let tag = create_tag("test_tag".to_string()).unwrap(); + let updated_tag = update_tag(TagApi { + id: tag.id, + title: "new_name".to_string(), + }) + .unwrap(); + assert_eq!(String::from("new_name"), updated_tag.title); + assert_eq!(Some(1), updated_tag.id); + // test that it's in the database + let updated_tag = get_tag(1).unwrap(); + assert_eq!(String::from("new_name"), updated_tag.title); + cleanup(); + } + + #[test] + fn update_tag_not_found() { + init_db_folder(); + let res = update_tag(TagApi { + id: Some(1), + title: "what".to_string(), + }); + assert_eq!(UpdateTagError::TagNotFound, res.unwrap_err()); + cleanup(); + } + + #[test] + fn update_tag_already_exists() { + init_db_folder(); + create_tag("first".to_string()).unwrap(); + create_tag("second".to_string()).unwrap(); + let res = update_tag(TagApi { + id: Some(2), + title: "FiRsT".to_string(), + }); + assert_eq!(UpdateTagError::NewNameAlreadyExists, res.unwrap_err()); + cleanup(); + } +} + +mod delete_tag_tests { + use crate::model::error::tag_errors::GetTagError; + use crate::tags::service::{create_tag, delete_tag, get_tag}; + use crate::test::{cleanup, init_db_folder}; + + #[test] + fn delete_tag_works() { + init_db_folder(); + create_tag("test".to_string()).unwrap(); + delete_tag(1).unwrap(); + let res = get_tag(1).unwrap_err(); + assert_eq!(GetTagError::TagNotFound, res); + cleanup(); + } +} + +mod update_file_tag_test { + use crate::model::error::tag_errors::TagRelationError; + use crate::model::file_types::FileTypes; + use crate::model::repository::FileRecord; + use crate::model::response::TaggedItemApi; + + use crate::tags::service::{create_tag, get_tags_on_file, update_file_tags}; + use crate::test::{cleanup, init_db_folder, now}; + + #[test] + fn update_file_tags_works() { + init_db_folder(); + create_tag("test".to_string()).unwrap(); + FileRecord { + id: None, + name: "test_file".to_string(), + parent_id: None, + size: 0, + create_date: now(), + file_type: FileTypes::Unknown, + } + .save_to_db(); + update_file_tags( + 1, + vec![ + TaggedItemApi { + tag_id: Some(1), + title: "test".to_string(), + implicit_from: None, + }, + TaggedItemApi { + tag_id: None, + title: "new tag".to_string(), + implicit_from: None, + }, + ], + ) + .unwrap(); + let expected = vec![ + TaggedItemApi { + tag_id: Some(1), + title: "test".to_string(), + implicit_from: None, + }, + TaggedItemApi { + tag_id: Some(2), + title: "new tag".to_string(), + implicit_from: None, + }, + ]; + let actual = get_tags_on_file(1).unwrap(); + assert_eq!(actual, expected); + cleanup(); + } + + #[test] + fn update_file_tags_removes_tags() { + init_db_folder(); + FileRecord { + id: None, + name: "test".to_string(), + parent_id: None, + size: 0, + create_date: now(), + file_type: FileTypes::Unknown, + } + .save_to_db(); + update_file_tags( + 1, + vec![TaggedItemApi { + tag_id: None, + title: "test".to_string(), + implicit_from: None, + }], + ) + .unwrap(); + update_file_tags(1, vec![]).unwrap(); + assert_eq!(get_tags_on_file(1).unwrap(), vec![]); + cleanup(); + } + + #[test] + fn update_file_tags_throws_error_if_file_not_found() { + init_db_folder(); + let res = update_file_tags(1, vec![]).unwrap_err(); + assert_eq!(TagRelationError::FileNotFound, res); + cleanup(); + } + + #[test] + fn update_file_tags_deduplicates_existing_tags() { + init_db_folder(); + create_tag("test".to_string()).unwrap(); + FileRecord { + id: None, + name: "test_file".to_string(), + parent_id: None, + size: 0, + create_date: now(), + file_type: FileTypes::Unknown, + } + .save_to_db(); + + // Try to add the same tag twice - should not fail and should only add it once + update_file_tags( + 1, + vec![ + TaggedItemApi { + tag_id: Some(1), + title: "test".to_string(), + implicit_from: None, + }, + TaggedItemApi { + tag_id: Some(1), + title: "test".to_string(), + implicit_from: None, + }, + ], + ) + .unwrap(); + + let actual = get_tags_on_file(1).unwrap(); + assert_eq!(actual.len(), 1); + assert_eq!(actual[0].tag_id, Some(1)); + assert_eq!(actual[0].title, "test"); + cleanup(); + } + + #[test] + fn update_file_tags_deduplicates_new_tags_with_same_name() { + init_db_folder(); + FileRecord { + id: None, + name: "test_file".to_string(), + parent_id: None, + size: 0, + create_date: now(), + file_type: FileTypes::Unknown, + } + .save_to_db(); + + // Create tag implicitly by name twice - should only create once + update_file_tags( + 1, + vec![ + TaggedItemApi { + tag_id: None, + title: "test".to_string(), + implicit_from: None, + }, + TaggedItemApi { + tag_id: None, + title: "test".to_string(), + implicit_from: None, + }, + ], + ) + .unwrap(); + + let actual = get_tags_on_file(1).unwrap(); + assert_eq!(actual.len(), 1); + assert_eq!(actual[0].tag_id, Some(1)); + assert_eq!(actual[0].title, "test"); + cleanup(); + } + + #[test] + fn update_file_tags_skips_duplicate_after_creating() { + init_db_folder(); + FileRecord { + id: None, + name: "test_file".to_string(), + parent_id: None, + size: 0, + create_date: now(), + file_type: FileTypes::Unknown, + } + .save_to_db(); + + // Mix of new tag by name and existing tag by id (same tag) + update_file_tags( + 1, + vec![TaggedItemApi { + tag_id: None, + title: "test".to_string(), + implicit_from: None, + }], + ) + .unwrap(); + + // Now update with both the id and a new tag with same name + update_file_tags( + 1, + vec![ + TaggedItemApi { + tag_id: Some(1), + title: "test".to_string(), + implicit_from: None, + }, + TaggedItemApi { + tag_id: None, + title: "test".to_string(), + implicit_from: None, + }, + ], + ) + .unwrap(); + + let actual = get_tags_on_file(1).unwrap(); + assert_eq!(actual.len(), 1); + assert_eq!(actual[0].tag_id, Some(1)); + assert_eq!(actual[0].title, "test"); + cleanup(); + } +} + +mod update_folder_tag_test { + use crate::model::error::tag_errors::TagRelationError; + use crate::model::repository::Folder; + use crate::model::response::TaggedItemApi; + use crate::repository::{folder_repository, open_connection}; + use crate::tags::service::{create_tag, get_tags_on_folder, update_folder_tags}; + use crate::test::{cleanup, init_db_folder}; + + #[test] + fn update_folder_tags_works() { + init_db_folder(); + let con = open_connection(); + create_tag("test".to_string()).unwrap(); + folder_repository::create_folder( + &Folder { + parent_id: None, + id: None, + name: "test_file".to_string(), + }, + &con, + ) + .unwrap(); + con.close().unwrap(); + update_folder_tags( + 1, + vec![ + TaggedItemApi { + tag_id: Some(1), + title: "test".to_string(), + implicit_from: None, + }, + TaggedItemApi { + tag_id: None, + title: "new tag".to_string(), + implicit_from: None, + }, + ], + ) + .unwrap(); + let expected = vec![ + TaggedItemApi { + tag_id: Some(1), + title: "test".to_string(), + implicit_from: None, + }, + TaggedItemApi { + tag_id: Some(2), + title: "new tag".to_string(), + implicit_from: None, + }, + ]; + let actual = get_tags_on_folder(1).unwrap(); + assert_eq!(actual, expected); + cleanup(); + } + + #[test] + fn update_folder_tags_removes_tags() { + init_db_folder(); + let con = open_connection(); + folder_repository::create_folder( + &Folder { + parent_id: None, + id: None, + name: "test".to_string(), + }, + &con, + ) + .unwrap(); + con.close().unwrap(); + update_folder_tags( + 1, + vec![TaggedItemApi { + tag_id: None, + title: "test".to_string(), + implicit_from: None, + }], + ) + .unwrap(); + update_folder_tags(1, vec![]).unwrap(); + assert_eq!(get_tags_on_folder(1).unwrap(), vec![]); + cleanup(); + } + + #[test] + fn update_folder_tags_throws_error_if_folder_not_found() { + init_db_folder(); + let res = update_folder_tags(1, vec![]).unwrap_err(); + assert_eq!(TagRelationError::FolderNotFound, res); + cleanup(); + } + + #[test] + fn update_folder_tags_deduplicates_existing_tags() { + init_db_folder(); + let con = open_connection(); + create_tag("test".to_string()).unwrap(); + folder_repository::create_folder( + &Folder { + parent_id: None, + id: None, + name: "test_folder".to_string(), + }, + &con, + ) + .unwrap(); + con.close().unwrap(); + + // Try to add the same tag twice - should not fail and should only add it once + update_folder_tags( + 1, + vec![ + TaggedItemApi { + tag_id: Some(1), + title: "test".to_string(), + implicit_from: None, + }, + TaggedItemApi { + tag_id: Some(1), + title: "test".to_string(), + implicit_from: None, + }, + ], + ) + .unwrap(); + + let actual = get_tags_on_folder(1).unwrap(); + assert_eq!(actual.len(), 1); + assert_eq!(actual[0].tag_id, Some(1)); + assert_eq!(actual[0].title, "test"); + cleanup(); + } + + #[test] + fn update_folder_tags_deduplicates_new_tags_with_same_name() { + init_db_folder(); + let con = open_connection(); + folder_repository::create_folder( + &Folder { + parent_id: None, + id: None, + name: "test_folder".to_string(), + }, + &con, + ) + .unwrap(); + con.close().unwrap(); + + // Create tag implicitly by name twice - should only create once + update_folder_tags( + 1, + vec![ + TaggedItemApi { + tag_id: None, + title: "test".to_string(), + implicit_from: None, + }, + TaggedItemApi { + tag_id: None, + title: "test".to_string(), + implicit_from: None, + }, + ], + ) + .unwrap(); + + let actual = get_tags_on_folder(1).unwrap(); + assert_eq!(actual.len(), 1); + assert_eq!(actual[0].tag_id, Some(1)); + assert_eq!(actual[0].title, "test"); + cleanup(); + } + + #[test] + fn update_folder_tags_skips_duplicate_after_creating() { + init_db_folder(); + let con = open_connection(); + folder_repository::create_folder( + &Folder { + parent_id: None, + id: None, + name: "test_folder".to_string(), + }, + &con, + ) + .unwrap(); + con.close().unwrap(); + + // Mix of new tag by name and existing tag by id (same tag) + update_folder_tags( + 1, + vec![TaggedItemApi { + tag_id: None, + title: "test".to_string(), + implicit_from: None, + }], + ) + .unwrap(); + + // Now update with both the id and a new tag with same name + update_folder_tags( + 1, + vec![ + TaggedItemApi { + tag_id: Some(1), + title: "test".to_string(), + implicit_from: None, + }, + TaggedItemApi { + tag_id: None, + title: "test".to_string(), + implicit_from: None, + }, + ], + ) + .unwrap(); + + let actual = get_tags_on_folder(1).unwrap(); + assert_eq!(actual.len(), 1); + assert_eq!(actual[0].tag_id, Some(1)); + assert_eq!(actual[0].title, "test"); + cleanup(); + } +} + +mod get_tags_on_file_tests { + use crate::model::error::tag_errors::TagRelationError; + use crate::tags::service::get_tags_on_file; + use crate::test::{cleanup, init_db_folder}; + + #[test] + fn throws_error_if_file_not_found() { + init_db_folder(); + let err = get_tags_on_file(1).unwrap_err(); + assert_eq!(TagRelationError::FileNotFound, err); + cleanup(); + } +} + +mod get_tags_on_folder_tests { + use crate::model::error::tag_errors::TagRelationError; + use crate::tags::service::get_tags_on_folder; + use crate::test::{cleanup, init_db_folder}; + + #[test] + fn throws_error_if_file_not_found() { + init_db_folder(); + let err = get_tags_on_folder(1).unwrap_err(); + assert_eq!(TagRelationError::FileNotFound, err); + cleanup(); + } +} + +mod pass_tags_to_descendants_tests { + + use crate::repository::open_connection; + use crate::tags::repository as tag_repository; + use crate::tags::service::*; + use crate::test::{ + cleanup, create_file_db_entry, create_folder_db_entry, create_tag_db_entry, + create_tag_folder, init_db_folder, + }; + + #[test] + fn should_imply_tag_to_descendant_folders() { + init_db_folder(); + // Create folder hierarchy: parent -> child -> grandchild + create_folder_db_entry("parent", None); // id 1 + create_folder_db_entry("child", Some(1)); // id 2 + create_folder_db_entry("grandchild", Some(2)); // id 3 + + // Add tag to parent + create_tag_folder("test_tag", 1); + + // Pass tags to children + pass_tags_to_descendants(1).unwrap(); + + // Check child has implicit tag + let child_tags = get_tags_on_folder(2).unwrap(); + assert_eq!(child_tags.len(), 1); + assert_eq!(child_tags[0].tag_id, Some(1)); + assert_eq!(child_tags[0].title, "test_tag"); + assert_eq!(child_tags[0].implicit_from, Some(1)); + + // Check grandchild has implicit tag + let grandchild_tags = get_tags_on_folder(3).unwrap(); + assert_eq!(grandchild_tags.len(), 1); + assert_eq!(grandchild_tags[0].tag_id, Some(1)); + assert_eq!(grandchild_tags[0].title, "test_tag"); + assert_eq!(grandchild_tags[0].implicit_from, Some(1)); + + cleanup(); + } + + #[test] + fn should_imply_tag_to_descendant_files() { + init_db_folder(); + // Create folder hierarchy: parent -> child + create_folder_db_entry("parent", None); // id 1 + create_folder_db_entry("child", Some(1)); // id 2 + + // Create files in folders + create_file_db_entry("file1.txt", Some(1)); // id 1 + create_file_db_entry("file2.txt", Some(2)); // id 2 + + // Add tag to parent + create_tag_folder("test_tag", 1); + + // Pass tags to children + pass_tags_to_descendants(1).unwrap(); + + // Check file in parent has implicit tag + let file1_tags = get_tags_on_file(1).unwrap(); + assert_eq!(file1_tags.len(), 1); + assert_eq!(file1_tags[0].tag_id, Some(1)); + assert_eq!(file1_tags[0].title, "test_tag"); + assert_eq!(file1_tags[0].implicit_from, Some(1)); + + // Check file in child has implicit tag + let file2_tags = get_tags_on_file(2).unwrap(); + assert_eq!(file2_tags.len(), 1); + assert_eq!(file2_tags[0].tag_id, Some(1)); + assert_eq!(file2_tags[0].title, "test_tag"); + assert_eq!(file2_tags[0].implicit_from, Some(1)); + + cleanup(); + } + + #[test] + fn should_not_override_explicit_tags_on_folders() { + init_db_folder(); + // Create folder hierarchy: parent -> child + create_folder_db_entry("parent", None); // id 1 + create_folder_db_entry("child", Some(1)); // id 2 + + let tag_id = create_tag_db_entry("test_tag"); + let con = open_connection(); + tag_repository::add_explicit_tag_to_folder(1, tag_id, &con).unwrap(); + tag_repository::add_explicit_tag_to_folder(2, tag_id, &con).unwrap(); + con.close().unwrap(); + + // Pass tags to children + pass_tags_to_descendants(1).unwrap(); + + // Check child still has explicit tag (not implicit) + let child_tags = get_tags_on_folder(2).unwrap(); + assert_eq!(child_tags.len(), 1); + assert_eq!(child_tags[0].tag_id, Some(tag_id)); + assert_eq!(child_tags[0].title, "test_tag"); + assert_eq!(child_tags[0].implicit_from, None); // Still explicit + + cleanup(); + } + + #[test] + fn should_not_override_explicit_tags_on_files() { + init_db_folder(); + // Create folder with file + create_folder_db_entry("parent", None); // id 1 + create_file_db_entry("file.txt", Some(1)); // id 1 + + let tag_id = create_tag_db_entry("test_tag"); + let con = open_connection(); + tag_repository::add_explicit_tag_to_folder(1, tag_id, &con).unwrap(); + tag_repository::add_explicit_tag_to_file(1, tag_id, &con).unwrap(); + con.close().unwrap(); + + // Pass tags to children + pass_tags_to_descendants(1).unwrap(); + + // Check file still has explicit tag (not implicit) + let file_tags = get_tags_on_file(1).unwrap(); + assert_eq!(file_tags.len(), 1); + assert_eq!(file_tags[0].tag_id, Some(tag_id)); + assert_eq!(file_tags[0].title, "test_tag"); + assert_eq!(file_tags[0].implicit_from, None); // Still explicit + + cleanup(); + } + + #[test] + fn should_remove_implicit_tags_when_folder_tag_removed() { + init_db_folder(); + // Create folder hierarchy: parent -> child + create_folder_db_entry("parent", None); // id 1 + create_folder_db_entry("child", Some(1)); // id 2 + + // Add tag to parent and propagate + create_tag_folder("test_tag", 1); + pass_tags_to_descendants(1).unwrap(); + + // Verify child has implicit tag + let child_tags = get_tags_on_folder(2).unwrap(); + assert_eq!(child_tags.len(), 1); + assert_eq!(child_tags[0].implicit_from, Some(1)); + + let con = open_connection(); + tag_repository::remove_explicit_tag_from_folder(1, 1, &con).unwrap(); + con.close().unwrap(); + + // Propagate the change + pass_tags_to_descendants(1).unwrap(); + + // Check child no longer has the tag + let child_tags = get_tags_on_folder(2).unwrap(); + assert_eq!(child_tags.len(), 0); + + cleanup(); + } + + #[test] + fn should_reinherit_from_higher_ancestor_when_tag_removed() { + init_db_folder(); + // Create folder hierarchy: grandparent -> parent -> child + create_folder_db_entry("grandparent", None); // id 1 + create_folder_db_entry("parent", Some(1)); // id 2 + create_folder_db_entry("child", Some(2)); // id 3 + + // Create tag and add explicitly to both grandparent and parent + let tag_id = create_tag_db_entry("test_tag"); + let con = open_connection(); + tag_repository::add_explicit_tag_to_folder(1, tag_id, &con).unwrap(); + tag_repository::add_explicit_tag_to_folder(2, tag_id, &con).unwrap(); + con.close().unwrap(); + // current state: grandparent+test_tag/parent+test_tag/child + pass_tags_to_descendants(2).unwrap(); + + // Child should inherit from parent (closer ancestor) + let child_tags = get_tags_on_folder(3).unwrap(); + assert_eq!(child_tags.len(), 1); + assert_eq!(child_tags[0].implicit_from, Some(2)); + + update_folder_tags(2, vec![]).unwrap(); + + // Child should now inherit from grandparent + let child_tags = get_tags_on_folder(3).unwrap(); + assert_eq!(child_tags.len(), 1); + assert_eq!(child_tags[0].tag_id, Some(tag_id)); + assert_eq!(child_tags[0].implicit_from, Some(1)); + + cleanup(); + } + + #[test] + fn should_inherit_from_closest_ancestor_folder() { + init_db_folder(); + // Create folder hierarchy: top -> middle -> bottom + create_folder_db_entry("top", None); // id 1 + create_folder_db_entry("middle", Some(1)); // id 2 + create_folder_db_entry("bottom", Some(2)); // id 3 + + // Create tag once + let tag_id = create_tag_db_entry("test_tag"); + + // Add tag to bottom first + let con = open_connection(); + tag_repository::add_explicit_tag_to_folder(3, tag_id, &con).unwrap(); + con.close().unwrap(); + pass_tags_to_descendants(3).unwrap(); + + // Then add same tag to middle + let con = open_connection(); + tag_repository::add_explicit_tag_to_folder(2, tag_id, &con).unwrap(); + con.close().unwrap(); + pass_tags_to_descendants(2).unwrap(); + + // Bottom should still have it as explicit + let bottom_tags = get_tags_on_folder(3).unwrap(); + assert_eq!(bottom_tags.len(), 1); + assert_eq!(bottom_tags[0].implicit_from, None); // Explicit + + cleanup(); + } + + #[test] + fn should_inherit_from_closest_ancestor_file() { + init_db_folder(); + // Create folder hierarchy: top -> middle -> bottom (with file) + create_folder_db_entry("top", None); // id 1 + create_folder_db_entry("middle", Some(1)); // id 2 + create_folder_db_entry("bottom", Some(2)); // id 3 + create_file_db_entry("file.png", Some(3)); + + // Create tag once + let tag_id = create_tag_db_entry("test_tag"); + + // Add tag to bottom + let con = open_connection(); + tag_repository::add_explicit_tag_to_folder(3, tag_id, &con).unwrap(); + con.close().unwrap(); + pass_tags_to_descendants(3).unwrap(); + + // File should inherit from bottom + let file_tags = get_tags_on_file(1).unwrap(); + assert_eq!(file_tags.len(), 1); + assert_eq!(file_tags[0].implicit_from, Some(3)); + + // Add same tag to middle - file should still inherit from bottom (closer) + let con = open_connection(); + tag_repository::add_explicit_tag_to_folder(2, tag_id, &con).unwrap(); + con.close().unwrap(); + pass_tags_to_descendants(2).unwrap(); + + // File should still inherit from bottom (id 3), not middle (id 2) + let file_tags = get_tags_on_file(1).unwrap(); + assert_eq!(file_tags.len(), 1); + assert_eq!(file_tags[0].implicit_from, Some(3)); + + cleanup(); + } + + #[test] + fn removing_tag_from_distant_ancestor_should_not_affect_closer_inheritance() { + init_db_folder(); + // Create folder hierarchy: top -> middle -> bottom + create_folder_db_entry("top", None); // id 1 + create_folder_db_entry("middle", Some(1)); // id 2 + create_folder_db_entry("bottom", Some(2)); // id 3 + + // Add tag to all three levels + let tag_id = create_tag_db_entry("test_tag"); + let con = open_connection(); + tag_repository::add_explicit_tag_to_folder(1, tag_id, &con).unwrap(); + tag_repository::add_explicit_tag_to_folder(2, tag_id, &con).unwrap(); + tag_repository::add_explicit_tag_to_folder(3, tag_id, &con).unwrap(); + con.close().unwrap(); + + pass_tags_to_descendants(1).unwrap(); + pass_tags_to_descendants(2).unwrap(); + pass_tags_to_descendants(3).unwrap(); + + // Bottom should have explicit tag + let bottom_tags = get_tags_on_folder(3).unwrap(); + assert_eq!(bottom_tags.len(), 1); + assert_eq!(bottom_tags[0].implicit_from, None); + + // Remove tag from top - bottom should still have it explicitly + update_folder_tags(1, vec![]).unwrap(); + + let bottom_tags = get_tags_on_folder(3).unwrap(); + assert_eq!(bottom_tags.len(), 1); + assert_eq!(bottom_tags[0].implicit_from, None); + + // Remove tag from middle - bottom should still have it explicitly + update_folder_tags(2, vec![]).unwrap(); + + let bottom_tags = get_tags_on_folder(3).unwrap(); + assert_eq!(bottom_tags.len(), 1); + assert_eq!(bottom_tags[0].implicit_from, None); + + cleanup(); + } +} + +mod imply_all_ancestor_tags_tests { + use crate::repository::open_connection; + use crate::tags::repository as tag_repository; + use crate::tags::service::{get_tags_on_file, imply_all_ancestor_tags}; + use crate::test::*; + + #[test] + fn implies_tags_from_all_ancestors() { + init_db_folder(); + // Create folder hierarchy: A -> B -> C + create_folder_db_entry("A", None); // id 1 + create_folder_db_entry("B", Some(1)); // id 2 + create_folder_db_entry("C", Some(2)); // id 3 + + // Add explicit tags to each folder + create_tag_folder("tagA", 1); // A has tagA, creates tag id 1 + create_tag_folder("tagB", 2); // B has tagB, creates tag id 2 + create_tag_folder("tagC", 3); // C has tagC, creates tag id 3 + + // Create file in folder C + create_file_db_entry("test.txt", Some(3)); // id 1 + + // Imply ancestor tags + imply_all_ancestor_tags(1).unwrap(); + + let tags = get_tags_on_file(1).unwrap(); + + // File should have all three tags implied + assert_eq!(tags.len(), 3); + + // Find each tag and verify it's implicated from the correct folder + let tag_a_item = tags.iter().find(|t| t.tag_id == Some(1)).unwrap(); + let tag_b_item = tags.iter().find(|t| t.tag_id == Some(2)).unwrap(); + let tag_c_item = tags.iter().find(|t| t.tag_id == Some(3)).unwrap(); + + assert_eq!(tag_c_item.implicit_from, Some(3)); // C is closest + assert_eq!(tag_b_item.implicit_from, Some(2)); // B is middle + assert_eq!(tag_a_item.implicit_from, Some(1)); // A is furthest + + cleanup(); + } + + #[test] + fn does_not_override_explicit_tags() { + init_db_folder(); + // Create folder hierarchy: A -> B + create_folder_db_entry("A", None); // id 1 + create_folder_db_entry("B", Some(1)); // id 2 + + // Add same tag explicitly to both folders + let tag_id = create_tag_db_entry("sharedTag"); // Creates tag id 1 + let con = open_connection(); + tag_repository::add_explicit_tag_to_folder(1, tag_id, &con).unwrap(); // A has sharedTag + tag_repository::add_explicit_tag_to_folder(2, tag_id, &con).unwrap(); // B has sharedTag + con.close().unwrap(); + + // Create file in folder B with explicit sharedTag + create_file_db_entry("test.txt", Some(2)); // id 1 + let con2 = open_connection(); + tag_repository::add_explicit_tag_to_file(1, tag_id, &con2).unwrap(); // File explicitly has sharedTag + con2.close().unwrap(); + + // Imply ancestor tags + imply_all_ancestor_tags(1).unwrap(); + + let tags = get_tags_on_file(1).unwrap(); + + // File should still have only 1 tag (explicit version) + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].title, "sharedTag"); + assert_eq!(tags[0].implicit_from, None); // Should remain explicit + + cleanup(); + } + + #[test] + fn handles_file_in_root() { + init_db_folder(); + // Create file in root (no parent folder) + create_file_db_entry("test.txt", None); // id 1 + + // Should not error and should result in no implied tags + imply_all_ancestor_tags(1).unwrap(); + + let tags = get_tags_on_file(1).unwrap(); + assert_eq!(tags.len(), 0); + + cleanup(); + } + + #[test] + fn returns_error_if_file_not_found() { + init_db_folder(); + + let result = imply_all_ancestor_tags(999); + + assert!(result.is_err()); + + cleanup(); + } + + #[test] + fn closest_ancestor_takes_precedence() { + init_db_folder(); + // Create folder hierarchy: A -> B + create_folder_db_entry("A", None); // id 1 + create_folder_db_entry("B", Some(1)); // id 2 + + // Both folders have the same tag + let tag_id = create_tag_db_entry("duplicateTag"); // Creates tag id 1 + let con = open_connection(); + tag_repository::add_explicit_tag_to_folder(1, tag_id, &con).unwrap(); // A has duplicateTag + tag_repository::add_explicit_tag_to_folder(2, tag_id, &con).unwrap(); // B has duplicateTag + con.close().unwrap(); + + // Create file in folder B + create_file_db_entry("test.txt", Some(2)); // id 1 + + // Imply ancestor tags + imply_all_ancestor_tags(1).unwrap(); + + let tags = get_tags_on_file(1).unwrap(); + + // File should have only 1 tag, implicated from B (closest ancestor) + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].title, "duplicateTag"); + assert_eq!(tags[0].implicit_from, Some(2)); // From B, not A + + cleanup(); + } +} diff --git a/src/test/api_handler_tests.rs b/src/test/api_handler_tests.rs index c086328..bd0e02f 100644 --- a/src/test/api_handler_tests.rs +++ b/src/test/api_handler_tests.rs @@ -25,7 +25,7 @@ fn version() { let client = Client::tracked(rocket()).expect("Valid Rocket Instance"); let res = client.get(uri!("/api/version")).dispatch(); assert_eq!(res.status(), Status::Ok); - assert_eq!(res.into_string().unwrap(), r#"{"version":"3.0.3"}"#); + assert_eq!(res.into_string().unwrap(), r#"{"version":"4.0.0"}"#); cleanup(); } diff --git a/src/test/file_handler_tests.rs b/src/test/file_handler_tests.rs index ff259a4..2d32526 100644 --- a/src/test/file_handler_tests.rs +++ b/src/test/file_handler_tests.rs @@ -772,3 +772,141 @@ fn regenerate_previews_bad_auth() { assert_eq!(res.status(), Status::Unauthorized); cleanup(); } + +#[test] +fn upload_file_should_implicate_all_ancestor_tags() { + set_password(); + remove_files(); + // Create folder hierarchy: A -> B -> C + create_folder_db_entry("A", None); // id 1 + create_folder_db_entry("B", Some(1)); // id 2 + create_folder_db_entry("C", Some(2)); // id 3 + create_folder_disk("A/B/C"); + + // Add tags to the ancestor folders + create_tag_folder("tagA", 1); // tag id 1 on folder A + create_tag_folder("tagB", 2); // tag id 2 on folder B + create_tag_folder("tagC", 3); // tag id 3 on folder C + + let client = client(); + let body = "--BOUNDARY\r\n\ +Content-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n\ +Content-Type: text/plain\r\n\ +\r\n\ +agk=\r\n\ +\r\n\ +--BOUNDARY\r\n\ +Content-Disposition: form-data; name=\"extension\"\r\n\ +\r\n\ +txt\r\n\ +--BOUNDARY\r\n\ +Content-Disposition: form-data; name=\"folderId\"\r\n\ +\r\n\ +3\r\n\ +--BOUNDARY--"; + + let res = client + .post(uri!("/files")) + .header(Header::new("Authorization", AUTH)) + .header(Header::new( + "Content-Type", + "multipart/form-data; boundary=BOUNDARY", + )) + .body(body) + .dispatch(); + + assert_eq!(res.status(), Status::Created); + let created_file: FileApi = res.into_json().unwrap(); + + // Fetch the file metadata to get the tags (they may not be in the create response) + let file_res = client + .get(format!("/files/metadata/{}", created_file.id)) + .header(Header::new("Authorization", AUTH)) + .dispatch(); + + let file: FileApi = file_res.into_json().unwrap(); + + // Verify the file has all ancestor tags implied + assert_eq!(file.tags.len(), 3, "File should have 3 ancestor tags"); + + // Verify each tag is present and is implied from the correct folder + let tag_a = file + .tags + .iter() + .find(|t| t.title == "tagA") + .expect("tagA should be present"); + assert_eq!( + tag_a.implicit_from, + Some(1), + "tagA should be implied from folder A" + ); + + let tag_b = file + .tags + .iter() + .find(|t| t.title == "tagB") + .expect("tagB should be present"); + assert_eq!( + tag_b.implicit_from, + Some(2), + "tagB should be implied from folder B" + ); + + let tag_c = file + .tags + .iter() + .find(|t| t.title == "tagC") + .expect("tagC should be present"); + assert_eq!( + tag_c.implicit_from, + Some(3), + "tagC should be implied from folder C" + ); + + cleanup(); +} + +#[test] +fn upload_file_should_implicate_no_tags_if_ancestor_is_root() { + set_password(); + remove_files(); + + let client = client(); + let body = "--BOUNDARY\r\n\ +Content-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n\ +Content-Type: text/plain\r\n\ +\r\n\ +agk=\r\n\ +\r\n\ +--BOUNDARY\r\n\ +Content-Disposition: form-data; name=\"extension\"\r\n\ +\r\n\ +txt\r\n\ +--BOUNDARY\r\n\ +Content-Disposition: form-data; name=\"folderId\"\r\n\ +\r\n\ +0\r\n\ +--BOUNDARY--"; + + let res = client + .post(uri!("/files")) + .header(Header::new("Authorization", AUTH)) + .header(Header::new( + "Content-Type", + "multipart/form-data; boundary=BOUNDARY", + )) + .body(body) + .dispatch(); + + assert_eq!(res.status(), Status::Created); + let file: FileApi = res.into_json().unwrap(); + + // Verify the file has no tags (root has no tags to imply) + assert_eq!( + file.tags.len(), + 0, + "File in root should have no implied tags" + ); + + cleanup(); +} diff --git a/src/test/folder_handler_tests.rs b/src/test/folder_handler_tests.rs index a73143b..ccbda5f 100644 --- a/src/test/folder_handler_tests.rs +++ b/src/test/folder_handler_tests.rs @@ -683,7 +683,7 @@ fn update_folder_to_file_with_same_name_root() { assert_eq!(res_body.message, "A file with that name already exists."); // verify the database hasn't changed (file id 1 should be named file in root folder) let con = open_connection(); - let root_files = folder_repository::get_child_files([], &con).unwrap_or(vec![]); + let root_files = folder_repository::get_child_files(&[], &con).unwrap_or_default(); assert_eq!( root_files[0], FileRecord { diff --git a/src/test/mod.rs b/src/test/mod.rs index 28b2795..f613983 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -8,13 +8,14 @@ pub use tests::*; #[cfg(test)] mod tests { use crate::model::api::FileApi; - use crate::model::repository::{FileRecord, Folder, Tag}; + use crate::model::repository::{FileRecord, Folder}; use crate::previews; - use crate::repository::{ - file_repository, folder_repository, initialize_db, open_connection, tag_repository, - }; + use crate::repository::{file_repository, folder_repository, initialize_db, open_connection}; use crate::service::file_service::{determine_file_type, file_dir}; + use crate::tags::Tag; + use crate::tags::repository as tag_repository; use crate::temp_dir; + use rocket::local::blocking::Client; use std::fs; use std::fs::{remove_dir_all, remove_file}; use std::io::Write; @@ -23,6 +24,22 @@ mod tests { /// username:password pub static AUTH: &str = "Basic dXNlcm5hbWU6cGFzc3dvcmQ="; + /// Creates a Rocket test client for handler tests + pub fn client() -> Client { + Client::tracked(crate::rocket()).unwrap() + } + + /// Sets up authentication by creating a password in the test database + pub fn set_password() { + init_db_folder(); + let client = client(); + let uri = uri!("/api/password"); + client + .post(uri) + .body(r#"{"username":"username","password":"password"}"#) + .dispatch(); + } + pub fn init_db_folder() { // since this is just for testing, we don't need to unwrap the logging let _ = fern::Dispatch::new() @@ -109,15 +126,40 @@ mod tests { pub fn create_tag_folder(name: &str, folder_id: u32) { let connection = open_connection(); let id = create_tag_db_entry(name); - tag_repository::add_tag_to_folder(folder_id, id, &connection).unwrap(); + tag_repository::add_explicit_tag_to_folder(folder_id, id, &connection).unwrap(); connection.close().unwrap(); } + pub fn imply_tag_on_file(tag_id: u32, file_id: u32, implicit_from_id: u32) { + let con = open_connection(); + let sql = format!( + "insert into TaggedItems(tagId, fileId, implicitFromId) values ({tag_id}, {file_id}, {implicit_from_id})" + ); + let mut pst = con.prepare(&sql).unwrap(); + pst.raw_execute().unwrap(); + // this is needed so that con isn't being shared anymore in this function's scope + drop(pst); + con.close().unwrap(); + } + + pub fn imply_tag_on_folder(tag_id: u32, folder_id: u32, implicit_from_id: u32) { + let con = open_connection(); + let sql = format!( + "insert into TaggedItems(tagId, folderId, implicitFromId) values ({tag_id}, {folder_id}, {implicit_from_id})" + ); + // scoped here so that the prepared statement gets dropped, which is needed to close the connection + let mut pst = con.prepare(&sql).unwrap(); + pst.raw_execute().unwrap(); + // this is needed so that con isn't being shared anymore in this function's scope + drop(pst); + con.close().unwrap(); + } + pub fn create_tag_folders(name: &str, folder_ids: Vec) { let connection = open_connection(); let id = create_tag_db_entry(name); for folder_id in folder_ids { - tag_repository::add_tag_to_folder(folder_id, id, &connection).unwrap(); + tag_repository::add_explicit_tag_to_folder(folder_id, id, &connection).unwrap(); } connection.close().unwrap(); } @@ -125,7 +167,7 @@ mod tests { pub fn create_tag_file(name: &str, file_id: u32) { let connection = open_connection(); let id = create_tag_db_entry(name); - tag_repository::add_tag_to_file(file_id, id, &connection).unwrap(); + tag_repository::add_explicit_tag_to_file(file_id, id, &connection).unwrap(); connection.close().unwrap(); } @@ -133,7 +175,7 @@ mod tests { let connection = open_connection(); let id = create_tag_db_entry(name); for file_id in file_ids { - tag_repository::add_tag_to_file(file_id, id, &connection).unwrap(); + tag_repository::add_explicit_tag_to_file(file_id, id, &connection).unwrap(); } connection.close().unwrap(); } @@ -224,8 +266,8 @@ mod tests { let file_id = file_repository::create_file(&record, &con).unwrap(); for tag in &mut self.tags { let Tag { id, title: _ } = tag_repository::create_tag(&tag.title, &con).unwrap(); - tag_repository::add_tag_to_file(file_id, id, &con).unwrap(); - tag.id = Some(id); + tag_repository::add_explicit_tag_to_file(file_id, id, &con).unwrap(); + tag.tag_id = Some(id); } if let Some(folder_id) = self.folder_id { folder_repository::link_folder_to_file(file_id, folder_id, &con).unwrap();