From c7a26910426d65be385dcafeae73265a66bbb52e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Nov 2025 08:38:06 -0500 Subject: [PATCH 01/61] Add inherited_from column to Files_Tags and Folders_Tags tables (#120) * Initial plan * Add v6 database migration for tag inheritance support Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> * Use shorthand syntax for foreign key references in v6 migration Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> * update file_tags database field to fileTags --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> Co-authored-by: ploiu --- src/assets/migration/v6.sql | 73 +++++++++++++++++++ src/db_migrations.rs | 141 ++++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 src/assets/migration/v6.sql diff --git a/src/assets/migration/v6.sql b/src/assets/migration/v6.sql new file mode 100644 index 0000000..91938ae --- /dev/null +++ b/src/assets/migration/v6.sql @@ -0,0 +1,73 @@ +begin; + +-- SQLite doesn't support adding columns with foreign keys via ALTER TABLE, +-- so we need to recreate the tables with the new schema +-- Create new Files_Tags table with inheritedfrom column +create table Files_Tags_new ( + fileRecordTagId integer primary key autoincrement, + fileRecordId integer not null references FileRecords (id) on delete cascade, + tagId integer not null references Tags (id) on delete cascade, + inheritedfrom integer references Folders (id) on delete cascade, + -- Unique constraint on fileRecordId and tagId effectively enforces uniqueness on tag title and file id + -- since Tags.title is unique and tagId maps 1:1 to title + unique (fileRecordId, tagId) +); + +-- Create new Folders_Tags table with inheritedfrom column +create table Folders_Tags_new ( + foldersTagId integer primary key autoincrement, + folderId integer not null references Folders (id) on delete cascade, + tagId integer not null references Tags (id) on delete cascade, + inheritedfrom integer references Folders (id) on delete cascade, + -- Unique constraint on folderId and tagId effectively enforces uniqueness on tag title and folder id + -- since Tags.title is unique and tagId maps 1:1 to title + unique (folderId, tagId) +); + +-- Copy data from old tables to new tables +insert into + Files_Tags_new( + fileRecordTagId, + fileRecordId, + tagId, + inheritedfrom + ) +select + fileRecordTagId, + fileRecordId, + tagId, + null +from + Files_Tags; + +insert into + Folders_Tags_new(foldersTagId, folderId, tagId, inheritedfrom) +select + foldersTagId, + folderId, + tagId, + null +from + Folders_Tags; + +-- Drop old tables +drop table Files_Tags; + +drop table Folders_Tags; + +-- Rename new tables to original names +alter table + Files_Tags_new rename to Files_Tags; + +alter table + Folders_Tags_new rename to Folders_Tags; + +-- Update version +update + Metadata +set + value = '6' +where + name = 'version'; + +commit; \ No newline at end of file diff --git a/src/db_migrations.rs b/src/db_migrations.rs index 65ccb38..af977a2 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,140 @@ 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")) +} + +#[cfg(test)] +mod migration_tests { + use crate::repository::open_connection; + use crate::test::{cleanup, init_db_folder}; + use rusqlite::params; + + #[test] + fn v6_migration_adds_inherited_from_columns() { + init_db_folder(); + let con = open_connection(); + + // Create test data + let tag_id = con + .execute("insert into Tags(title) values (?1)", params!["test_tag"]) + .unwrap(); + let file_id = con + .execute( + "insert into FileRecords(name) values (?1)", + params!["test_file.txt"], + ) + .unwrap(); + let folder_id = con + .execute( + "insert into Folders(name) values (?1)", + params!["test_folder"], + ) + .unwrap(); + + // Add tag to file and folder + con.execute( + "insert into Files_Tags(fileRecordId, tagId) values (?1, ?2)", + params![file_id, tag_id], + ) + .unwrap(); + con.execute( + "insert into Folders_Tags(folderId, tagId) values (?1, ?2)", + params![folder_id, tag_id], + ) + .unwrap(); + + // Verify inheritedFrom column exists and is NULL by default + let file_inherited: Option = con + .query_row( + "select inheritedFrom from Files_Tags where fileRecordId = ?1", + params![file_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(None, file_inherited); + + let folder_inherited: Option = con + .query_row( + "select inheritedFrom from Folders_Tags where folderId = ?1", + params![folder_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(None, folder_inherited); + + // Verify we can set inheritedFrom + let parent_folder_id = con + .execute( + "insert into Folders(name) values (?1)", + params!["parent_folder"], + ) + .unwrap(); + con.execute( + "update Files_Tags set inheritedFrom = ?1 where fileRecordId = ?2", + params![parent_folder_id, file_id], + ) + .unwrap(); + con.execute( + "update Folders_Tags set inheritedFrom = ?1 where folderId = ?2", + params![parent_folder_id, folder_id], + ) + .unwrap(); + + let file_inherited: Option = con + .query_row( + "select inheritedFrom from Files_Tags where fileRecordId = ?1", + params![file_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(Some(parent_folder_id as u32), file_inherited); + + let folder_inherited: Option = con + .query_row( + "select inheritedFrom from Folders_Tags where folderId = ?1", + params![folder_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(Some(parent_folder_id as u32), folder_inherited); + + con.close().unwrap(); + cleanup(); + } + + #[test] + fn v6_migration_preserves_unique_constraint() { + init_db_folder(); + let con = open_connection(); + + // Create test data + con.execute("insert into Tags(title) values (?1)", params!["test_tag"]) + .unwrap(); + let file_id = con + .execute( + "insert into FileRecords(name) values (?1)", + params!["test_file.txt"], + ) + .unwrap(); + + // Add tag to file + con.execute( + "insert into Files_Tags(fileRecordId, tagId) values (?1, 1)", + params![file_id], + ) + .unwrap(); + + // Try to add the same tag again - should fail due to unique constraint + let result = con.execute( + "insert into Files_Tags(fileRecordId, tagId) values (?1, 1)", + params![file_id], + ); + assert!(result.is_err(), "Expected unique constraint violation"); + + con.close().unwrap(); + cleanup(); + } +} From 3529e65bfdd28e7a86216abc19827b4e7682f270 Mon Sep 17 00:00:00 2001 From: ploiu Date: Sat, 15 Nov 2025 14:17:13 +0000 Subject: [PATCH 02/61] Revert "Add inherited_from column to Files_Tags and Folders_Tags tables (#120)" This reverts commit c7a26910426d65be385dcafeae73265a66bbb52e. --- src/assets/migration/v6.sql | 73 ------------------- src/db_migrations.rs | 141 ------------------------------------ 2 files changed, 214 deletions(-) delete mode 100644 src/assets/migration/v6.sql diff --git a/src/assets/migration/v6.sql b/src/assets/migration/v6.sql deleted file mode 100644 index 91938ae..0000000 --- a/src/assets/migration/v6.sql +++ /dev/null @@ -1,73 +0,0 @@ -begin; - --- SQLite doesn't support adding columns with foreign keys via ALTER TABLE, --- so we need to recreate the tables with the new schema --- Create new Files_Tags table with inheritedfrom column -create table Files_Tags_new ( - fileRecordTagId integer primary key autoincrement, - fileRecordId integer not null references FileRecords (id) on delete cascade, - tagId integer not null references Tags (id) on delete cascade, - inheritedfrom integer references Folders (id) on delete cascade, - -- Unique constraint on fileRecordId and tagId effectively enforces uniqueness on tag title and file id - -- since Tags.title is unique and tagId maps 1:1 to title - unique (fileRecordId, tagId) -); - --- Create new Folders_Tags table with inheritedfrom column -create table Folders_Tags_new ( - foldersTagId integer primary key autoincrement, - folderId integer not null references Folders (id) on delete cascade, - tagId integer not null references Tags (id) on delete cascade, - inheritedfrom integer references Folders (id) on delete cascade, - -- Unique constraint on folderId and tagId effectively enforces uniqueness on tag title and folder id - -- since Tags.title is unique and tagId maps 1:1 to title - unique (folderId, tagId) -); - --- Copy data from old tables to new tables -insert into - Files_Tags_new( - fileRecordTagId, - fileRecordId, - tagId, - inheritedfrom - ) -select - fileRecordTagId, - fileRecordId, - tagId, - null -from - Files_Tags; - -insert into - Folders_Tags_new(foldersTagId, folderId, tagId, inheritedfrom) -select - foldersTagId, - folderId, - tagId, - null -from - Folders_Tags; - --- Drop old tables -drop table Files_Tags; - -drop table Folders_Tags; - --- Rename new tables to original names -alter table - Files_Tags_new rename to Files_Tags; - -alter table - Folders_Tags_new rename to Folders_Tags; - --- Update version -update - Metadata -set - value = '6' -where - name = 'version'; - -commit; \ No newline at end of file diff --git a/src/db_migrations.rs b/src/db_migrations.rs index af977a2..65ccb38 100644 --- a/src/db_migrations.rs +++ b/src/db_migrations.rs @@ -112,10 +112,6 @@ 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(()) } @@ -142,140 +138,3 @@ 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")) -} - -#[cfg(test)] -mod migration_tests { - use crate::repository::open_connection; - use crate::test::{cleanup, init_db_folder}; - use rusqlite::params; - - #[test] - fn v6_migration_adds_inherited_from_columns() { - init_db_folder(); - let con = open_connection(); - - // Create test data - let tag_id = con - .execute("insert into Tags(title) values (?1)", params!["test_tag"]) - .unwrap(); - let file_id = con - .execute( - "insert into FileRecords(name) values (?1)", - params!["test_file.txt"], - ) - .unwrap(); - let folder_id = con - .execute( - "insert into Folders(name) values (?1)", - params!["test_folder"], - ) - .unwrap(); - - // Add tag to file and folder - con.execute( - "insert into Files_Tags(fileRecordId, tagId) values (?1, ?2)", - params![file_id, tag_id], - ) - .unwrap(); - con.execute( - "insert into Folders_Tags(folderId, tagId) values (?1, ?2)", - params![folder_id, tag_id], - ) - .unwrap(); - - // Verify inheritedFrom column exists and is NULL by default - let file_inherited: Option = con - .query_row( - "select inheritedFrom from Files_Tags where fileRecordId = ?1", - params![file_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(None, file_inherited); - - let folder_inherited: Option = con - .query_row( - "select inheritedFrom from Folders_Tags where folderId = ?1", - params![folder_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(None, folder_inherited); - - // Verify we can set inheritedFrom - let parent_folder_id = con - .execute( - "insert into Folders(name) values (?1)", - params!["parent_folder"], - ) - .unwrap(); - con.execute( - "update Files_Tags set inheritedFrom = ?1 where fileRecordId = ?2", - params![parent_folder_id, file_id], - ) - .unwrap(); - con.execute( - "update Folders_Tags set inheritedFrom = ?1 where folderId = ?2", - params![parent_folder_id, folder_id], - ) - .unwrap(); - - let file_inherited: Option = con - .query_row( - "select inheritedFrom from Files_Tags where fileRecordId = ?1", - params![file_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(Some(parent_folder_id as u32), file_inherited); - - let folder_inherited: Option = con - .query_row( - "select inheritedFrom from Folders_Tags where folderId = ?1", - params![folder_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(Some(parent_folder_id as u32), folder_inherited); - - con.close().unwrap(); - cleanup(); - } - - #[test] - fn v6_migration_preserves_unique_constraint() { - init_db_folder(); - let con = open_connection(); - - // Create test data - con.execute("insert into Tags(title) values (?1)", params!["test_tag"]) - .unwrap(); - let file_id = con - .execute( - "insert into FileRecords(name) values (?1)", - params!["test_file.txt"], - ) - .unwrap(); - - // Add tag to file - con.execute( - "insert into Files_Tags(fileRecordId, tagId) values (?1, 1)", - params![file_id], - ) - .unwrap(); - - // Try to add the same tag again - should fail due to unique constraint - let result = con.execute( - "insert into Files_Tags(fileRecordId, tagId) values (?1, 1)", - params![file_id], - ); - assert!(result.is_err(), "Expected unique constraint violation"); - - con.close().unwrap(); - cleanup(); - } -} From 40fca71ec7cc9d1ebcdabe5759856fce966333a4 Mon Sep 17 00:00:00 2001 From: ploiu Date: Sat, 15 Nov 2025 15:05:55 +0000 Subject: [PATCH 03/61] direct tag migration step --- src/assets/migration/v6.sql | 45 +++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/assets/migration/v6.sql diff --git a/src/assets/migration/v6.sql b/src/assets/migration/v6.sql new file mode 100644 index 0000000..a2040bd --- /dev/null +++ b/src/assets/migration/v6.sql @@ -0,0 +1,45 @@ +-- 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 + inheritedFromId 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; + + +-- inherit all + +commit; \ No newline at end of file From 1506fc6e614313041cacc914670c1aef8ad7b6ac Mon Sep 17 00:00:00 2001 From: ploiu Date: Sat, 15 Nov 2025 16:36:01 +0000 Subject: [PATCH 04/61] start of flattening tags --- .vscode/settings.json | 4 +-- src/assets/migration/v6.sql | 55 ++++++++++++++++++++++++++++++++++++- src/db_migrations.rs | 8 ++++++ src/exif/handler.rs | 11 ++------ 4 files changed, 67 insertions(+), 11 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 5a3d675..4e29738 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/src/assets/migration/v6.sql b/src/assets/migration/v6.sql index a2040bd..36baf25 100644 --- a/src/assets/migration/v6.sql +++ b/src/assets/migration/v6.sql @@ -39,7 +39,60 @@ select 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 --- inherit all + 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, inheritedFromId) +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 +); + +update metadata set value = 6 where name = 'version'; commit; \ No newline at end of file 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, From 7d72b56c3a880425826bf97a7235661f5234fe29 Mon Sep 17 00:00:00 2001 From: ploiu Date: Sat, 15 Nov 2025 17:07:44 +0000 Subject: [PATCH 05/61] v6 database migration --- src/assets/migration/v6.sql | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/assets/migration/v6.sql b/src/assets/migration/v6.sql index 36baf25..cacd95d 100644 --- a/src/assets/migration/v6.sql +++ b/src/assets/migration/v6.sql @@ -94,5 +94,47 @@ 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, inheritedFromId) +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 +); + update metadata set value = 6 where name = 'version'; commit; \ No newline at end of file From 578755ae8563021b29082059a8bd4cfd816f2a48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Nov 2025 17:27:55 +0000 Subject: [PATCH 06/61] Initial plan From f358bfaa299b4d3e6afed1c0774bbe4d543caeb5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Nov 2025 17:43:35 +0000 Subject: [PATCH 07/61] Move tag functionality to tags module Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- src/handler/mod.rs | 1 - src/main.rs | 4 +- src/repository/mod.rs | 1 - src/service/file_service.rs | 3 +- src/service/folder_service.rs | 6 +- src/service/mod.rs | 1 - src/service/search_service.rs | 3 +- .../tag_handler.rs => tags/handler.rs} | 10 +- src/tags/mod.rs | 6 + src/tags/repository.rs | 182 ++++++ .../tag_service.rs => tags/service.rs} | 542 +----------------- src/tags/tests/handler.rs | 1 + src/tags/tests/mod.rs | 3 + .../tests/repository.rs} | 217 +------ src/tags/tests/service.rs | 531 +++++++++++++++++ src/test/mod.rs | 3 +- 16 files changed, 756 insertions(+), 758 deletions(-) rename src/{handler/tag_handler.rs => tags/handler.rs} (95%) create mode 100644 src/tags/mod.rs create mode 100644 src/tags/repository.rs rename src/{service/tag_service.rs => tags/service.rs} (51%) create mode 100644 src/tags/tests/handler.rs create mode 100644 src/tags/tests/mod.rs rename src/{repository/tag_repository.rs => tags/tests/repository.rs} (56%) create mode 100644 src/tags/tests/service.rs 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/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/service/file_service.rs b/src/service/file_service.rs index 5fa765b..1c6b47e 100644 --- a/src/service/file_service.rs +++ b/src/service/file_service.rs @@ -23,7 +23,8 @@ use crate::model::request::file_requests::CreateFileRequest; 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::service as tag_service; use crate::{queue, repository}; /// mapping of file lowercase file extension => file type diff --git a/src/service/folder_service.rs b/src/service/folder_service.rs index 3d00d03..092aa6e 100644 --- a/src/service/folder_service.rs +++ b/src/service/folder_service.rs @@ -21,9 +21,11 @@ use crate::model::request::folder_requests::{CreateFolderRequest, UpdateFolderRe use crate::model::response::TagApi; 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::{check_root_dir, file_dir}; -use crate::service::{file_service, tag_service}; +use crate::service::file_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 { 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..d784c8c 100644 --- a/src/service/search_service.rs +++ b/src/service/search_service.rs @@ -10,8 +10,9 @@ 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::repository::{file_repository, folder_repository, open_connection}; use crate::service::folder_service; +use crate::tags::repository as tag_repository; pub fn search_files( search_title: &str, 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..5cbf913 100644 --- a/src/handler/tag_handler.rs +++ b/src/tags/handler.rs @@ -11,7 +11,7 @@ use crate::model::response::tag_responses::{ CreateTagResponse, DeleteTagResponse, GetTagResponse, UpdateTagResponse, }; use crate::model::response::{BasicMessage, TagApi}; -use crate::service::tag_service; +use crate::tags::service; use crate::util::update_last_request_time; #[get("/")] @@ -26,7 +26,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 +49,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 +69,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 +95,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..42506c7 --- /dev/null +++ b/src/tags/mod.rs @@ -0,0 +1,6 @@ +pub mod handler; +pub mod repository; +pub mod service; + +#[cfg(test)] +mod tests; diff --git a/src/tags/repository.rs b/src/tags/repository.rs new file mode 100644 index 0000000..a4edfd2 --- /dev/null +++ b/src/tags/repository.rs @@ -0,0 +1,182 @@ +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 }) +} diff --git a/src/service/tag_service.rs b/src/tags/service.rs similarity index 51% rename from src/service/tag_service.rs rename to src/tags/service.rs index 46882a4..8c09032 100644 --- a/src/service/tag_service.rs +++ b/src/tags/service.rs @@ -7,8 +7,9 @@ use crate::model::error::tag_errors::{ }; use crate::model::repository; use crate::model::response::TagApi; -use crate::repository::{open_connection, tag_repository}; +use crate::repository::open_connection; use crate::service::{file_service, folder_service}; +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 @@ -409,542 +410,3 @@ pub fn get_tags_on_folder(folder_id: u32) -> Result, TagRelationErro 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/tags/tests/handler.rs b/src/tags/tests/handler.rs new file mode 100644 index 0000000..78346ef --- /dev/null +++ b/src/tags/tests/handler.rs @@ -0,0 +1 @@ +// Handler tests will be added here as needed 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/repository/tag_repository.rs b/src/tags/tests/repository.rs similarity index 56% rename from src/repository/tag_repository.rs rename to src/tags/tests/repository.rs index 4a0a179..b5ea262 100644 --- a/src/repository/tag_repository.rs +++ b/src/tags/tests/repository.rs @@ -1,197 +1,14 @@ -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::repository::open_connection; + use crate::tags::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(); + let tag = repository::create_tag("test", &con).unwrap(); con.close().unwrap(); assert_eq!( Tag { @@ -204,11 +21,10 @@ mod create_tag_tests { } } -#[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::tags::repository::{create_tag, get_tag_by_title}; use crate::test::*; #[test] @@ -238,11 +54,10 @@ mod get_tag_by_title_tests { } } -#[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::tags::repository::{create_tag, get_tag}; use crate::test::{cleanup, init_db_folder}; #[test] @@ -263,11 +78,10 @@ mod get_tag_by_id_tests { } } -#[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::tags::repository::{create_tag, get_tag, update_tag}; use crate::test::{cleanup, init_db_folder}; #[test] @@ -296,10 +110,9 @@ mod update_tag_tests { } } -#[cfg(test)] mod delete_tag_tests { use crate::repository::open_connection; - use crate::repository::tag_repository::{create_tag, delete_tag, get_tag}; + use crate::tags::repository::{create_tag, delete_tag, get_tag}; use crate::test::{cleanup, init_db_folder}; #[test] @@ -315,9 +128,8 @@ mod delete_tag_tests { } } -#[cfg(test)] mod get_tag_on_file_tests { - use super::*; + use crate::tags::repository::*; use crate::model::file_types::FileTypes; use crate::model::repository::{FileRecord, Tag}; use crate::repository::file_repository::create_file; @@ -384,9 +196,8 @@ mod get_tag_on_file_tests { } } -#[cfg(test)] mod remove_tag_from_file_tests { - use super::*; + use crate::tags::repository::*; use crate::model::file_types::FileTypes; use crate::model::repository::{FileRecord, Tag}; use crate::repository::file_repository::create_file; @@ -418,12 +229,11 @@ mod remove_tag_from_file_tests { } } -#[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::tags::repository::{add_tag_to_folder, create_tag, get_tags_on_folder}; use crate::test::*; #[test] @@ -480,12 +290,11 @@ mod get_tag_on_folder_tests { } } -#[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::{ + use crate::tags::repository::{ create_tag, get_tags_on_folder, remove_tag_from_folder, }; use crate::test::{cleanup, init_db_folder}; @@ -512,11 +321,11 @@ mod remove_tag_from_folder_tests { } } -#[cfg(test)] mod get_tags_on_files_tests { use std::collections::HashMap; use crate::{model::repository::Tag, repository::open_connection, test::*}; + use crate::tags::repository::get_tags_on_files; #[test] fn returns_proper_mapping_for_file_tags() { @@ -528,7 +337,7 @@ mod get_tags_on_files_tests { 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(); + let res = get_tags_on_files(vec![1, 2, 3], &con).unwrap(); con.close().unwrap(); #[rustfmt::skip] let expected = HashMap::from([ diff --git a/src/tags/tests/service.rs b/src/tags/tests/service.rs new file mode 100644 index 0000000..c45a4d2 --- /dev/null +++ b/src/tags/tests/service.rs @@ -0,0 +1,531 @@ +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::TagApi; + + 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![ + 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(); + } +} + +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::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![ + 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(); + } +} + +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(); + } +} diff --git a/src/test/mod.rs b/src/test/mod.rs index 28b2795..9cd14ec 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -11,9 +11,10 @@ mod tests { use crate::model::repository::{FileRecord, Folder, Tag}; use crate::previews; use crate::repository::{ - file_repository, folder_repository, initialize_db, open_connection, tag_repository, + file_repository, folder_repository, initialize_db, open_connection, }; use crate::service::file_service::{determine_file_type, file_dir}; + use crate::tags::repository as tag_repository; use crate::temp_dir; use std::fs; use std::fs::{remove_dir_all, remove_file}; From 177f64cfdad2dbe771ba7a65a76772ec71a5b8dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Nov 2025 18:31:53 +0000 Subject: [PATCH 08/61] Use super imports for better module organization Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- src/tags/handler.rs | 3 ++- src/tags/repository.rs | 2 -- src/tags/tests/handler.rs | 3 ++- src/tags/tests/repository.rs | 20 ++++++++++---------- src/tags/tests/service.rs | 14 +++++++------- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/tags/handler.rs b/src/tags/handler.rs index 5cbf913..9a73265 100644 --- a/src/tags/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::tags::service; use crate::util::update_last_request_time; +use super::service; + #[get("/")] pub fn get_tag( id: u32, diff --git a/src/tags/repository.rs b/src/tags/repository.rs index a4edfd2..f380b78 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -73,8 +73,6 @@ pub fn get_tags_on_file( 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) diff --git a/src/tags/tests/handler.rs b/src/tags/tests/handler.rs index 78346ef..5711571 100644 --- a/src/tags/tests/handler.rs +++ b/src/tags/tests/handler.rs @@ -1 +1,2 @@ -// Handler tests will be added here as needed +// Tests for tag handlers +// TODO: Add integration tests for tag HTTP endpoints diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index b5ea262..4462851 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -1,7 +1,7 @@ mod create_tag_tests { use crate::model::repository::Tag; use crate::repository::open_connection; - use crate::tags::repository; + use super::super::super::repository; use crate::test::{cleanup, init_db_folder}; #[test] @@ -24,7 +24,7 @@ mod create_tag_tests { mod get_tag_by_title_tests { use crate::model::repository::Tag; use crate::repository::open_connection; - use crate::tags::repository::{create_tag, get_tag_by_title}; + use super::super::super::repository::{create_tag, get_tag_by_title}; use crate::test::*; #[test] @@ -57,7 +57,7 @@ mod get_tag_by_title_tests { mod get_tag_by_id_tests { use crate::model::repository::Tag; use crate::repository::open_connection; - use crate::tags::repository::{create_tag, get_tag}; + use super::super::super::repository::{create_tag, get_tag}; use crate::test::{cleanup, init_db_folder}; #[test] @@ -81,7 +81,7 @@ mod get_tag_by_id_tests { mod update_tag_tests { use crate::model::repository::Tag; use crate::repository::open_connection; - use crate::tags::repository::{create_tag, get_tag, update_tag}; + use super::super::super::repository::{create_tag, get_tag, update_tag}; use crate::test::{cleanup, init_db_folder}; #[test] @@ -112,7 +112,7 @@ mod update_tag_tests { mod delete_tag_tests { use crate::repository::open_connection; - use crate::tags::repository::{create_tag, delete_tag, get_tag}; + use super::super::super::repository::{create_tag, delete_tag, get_tag}; use crate::test::{cleanup, init_db_folder}; #[test] @@ -129,7 +129,7 @@ mod delete_tag_tests { } mod get_tag_on_file_tests { - use crate::tags::repository::*; + use super::super::super::repository::*; use crate::model::file_types::FileTypes; use crate::model::repository::{FileRecord, Tag}; use crate::repository::file_repository::create_file; @@ -197,7 +197,7 @@ mod get_tag_on_file_tests { } mod remove_tag_from_file_tests { - use crate::tags::repository::*; + use super::super::super::repository::*; use crate::model::file_types::FileTypes; use crate::model::repository::{FileRecord, Tag}; use crate::repository::file_repository::create_file; @@ -233,7 +233,7 @@ 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::tags::repository::{add_tag_to_folder, create_tag, get_tags_on_folder}; + use super::super::super::repository::{add_tag_to_folder, create_tag, get_tags_on_folder}; use crate::test::*; #[test] @@ -294,7 +294,7 @@ 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::tags::repository::{ + use super::super::super::repository::{ create_tag, get_tags_on_folder, remove_tag_from_folder, }; use crate::test::{cleanup, init_db_folder}; @@ -325,7 +325,7 @@ mod get_tags_on_files_tests { use std::collections::HashMap; use crate::{model::repository::Tag, repository::open_connection, test::*}; - use crate::tags::repository::get_tags_on_files; + use super::super::super::repository::get_tags_on_files; #[test] fn returns_proper_mapping_for_file_tags() { diff --git a/src/tags/tests/service.rs b/src/tags/tests/service.rs index c45a4d2..66f633c 100644 --- a/src/tags/tests/service.rs +++ b/src/tags/tests/service.rs @@ -1,6 +1,6 @@ mod get_tag_tests { use crate::model::error::tag_errors::GetTagError; - use crate::tags::service::{create_tag, get_tag}; + use super::super::super::service::{create_tag, get_tag}; use crate::test::*; #[test] @@ -24,7 +24,7 @@ mod get_tag_tests { 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 super::super::super::service::{create_tag, get_tag, update_tag}; use crate::test::{cleanup, init_db_folder}; #[test] @@ -71,7 +71,7 @@ mod update_tag_tests { mod delete_tag_tests { use crate::model::error::tag_errors::GetTagError; - use crate::tags::service::{create_tag, delete_tag, get_tag}; + use super::super::super::service::{create_tag, delete_tag, get_tag}; use crate::test::{cleanup, init_db_folder}; #[test] @@ -91,7 +91,7 @@ mod update_file_tag_test { use crate::model::repository::FileRecord; use crate::model::response::TagApi; - use crate::tags::service::{create_tag, get_tags_on_file, update_file_tags}; + use super::super::super::service::{create_tag, get_tags_on_file, update_file_tags}; use crate::test::{cleanup, init_db_folder, now}; #[test] @@ -294,7 +294,7 @@ mod update_folder_tag_test { use crate::model::repository::Folder; use crate::model::response::TagApi; use crate::repository::{folder_repository, open_connection}; - use crate::tags::service::{create_tag, get_tags_on_folder, update_folder_tags}; + use super::super::super::service::{create_tag, get_tags_on_folder, update_folder_tags}; use crate::test::{cleanup, init_db_folder}; #[test] @@ -504,7 +504,7 @@ mod update_folder_tag_test { mod get_tags_on_file_tests { use crate::model::error::tag_errors::TagRelationError; - use crate::tags::service::get_tags_on_file; + use super::super::super::service::get_tags_on_file; use crate::test::{cleanup, init_db_folder}; #[test] @@ -518,7 +518,7 @@ mod get_tags_on_file_tests { mod get_tags_on_folder_tests { use crate::model::error::tag_errors::TagRelationError; - use crate::tags::service::get_tags_on_folder; + use super::super::super::service::get_tags_on_folder; use crate::test::{cleanup, init_db_folder}; #[test] From aea2f501600ac2baaa8168f4e6e788231f889e30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Nov 2025 18:52:03 +0000 Subject: [PATCH 09/61] Add handler tests for tag endpoints Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- src/tags/tests/handler.rs | 156 +++++++++++++++++++++++++++++++++++++- 1 file changed, 154 insertions(+), 2 deletions(-) diff --git a/src/tags/tests/handler.rs b/src/tags/tests/handler.rs index 5711571..0ff5b09 100644 --- a/src/tags/tests/handler.rs +++ b/src/tags/tests/handler.rs @@ -1,2 +1,154 @@ -// Tests for tag handlers -// TODO: Add integration tests for tag HTTP endpoints +use rocket::http::{Header, Status}; +use rocket::local::blocking::Client; + +use crate::model::response::TagApi; +use crate::repository::initialize_db; +use crate::rocket; +use crate::test::*; + +fn client() -> Client { + Client::tracked(rocket()).unwrap() +} + +fn set_password() { + init_db_folder(); + let client = client(); + let uri = uri!("/api/password"); + client + .post(uri) + .body(r#"{"username":"username","password":"password"}"#) + .dispatch(); +} + +#[test] +fn get_tag_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 get_tag_success() { + set_password(); + create_tag_db_entry("test_tag"); + let client = client(); + let auth = Header::new("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ="); + let res = client.get(uri!("/tags/1")).header(auth).dispatch(); + assert_eq!(res.status(), Status::Ok); + cleanup(); +} + +#[test] +fn get_tag_not_found() { + set_password(); + let client = client(); + let auth = Header::new("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ="); + let res = client.get(uri!("/tags/999")).header(auth).dispatch(); + assert_eq!(res.status(), Status::NotFound); + cleanup(); +} + +#[test] +fn create_tag_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 create_tag_success() { + set_password(); + let client = client(); + let auth = Header::new("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ="); + let res = client + .post(uri!("/tags")) + .header(auth) + .body(r#"{"title":"new_tag"}"#) + .dispatch(); + assert_eq!(res.status(), Status::Created); + cleanup(); +} + +#[test] +fn update_tag_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 update_tag_success() { + set_password(); + create_tag_db_entry("original_tag"); + let client = client(); + let auth = Header::new("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ="); + 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 update_tag_not_found() { + set_password(); + let client = client(); + let auth = Header::new("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ="); + 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 update_tag_already_exists() { + set_password(); + create_tag_db_entry("tag1"); + create_tag_db_entry("tag2"); + let client = client(); + let auth = Header::new("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ="); + let res = client + .put(uri!("/tags")) + .header(auth) + .body(r#"{"id":2,"title":"tag1"}"#) + .dispatch(); + assert_eq!(res.status(), Status::BadRequest); + cleanup(); +} + +#[test] +fn delete_tag_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 delete_tag_success() { + set_password(); + create_tag_db_entry("test_tag"); + let client = client(); + let auth = Header::new("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ="); + let res = client.delete(uri!("/tags/1")).header(auth).dispatch(); + assert_eq!(res.status(), Status::NoContent); + cleanup(); +} From e59639cb6e4e4bf5f43426f118e7bc2c0457fc34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Nov 2025 18:58:09 +0000 Subject: [PATCH 10/61] Use crate:: instead of super::super::super:: for cleaner imports Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- src/tags/tests/repository.rs | 20 ++++++++++---------- src/tags/tests/service.rs | 14 +++++++------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index 4462851..b5ea262 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -1,7 +1,7 @@ mod create_tag_tests { use crate::model::repository::Tag; use crate::repository::open_connection; - use super::super::super::repository; + use crate::tags::repository; use crate::test::{cleanup, init_db_folder}; #[test] @@ -24,7 +24,7 @@ mod create_tag_tests { mod get_tag_by_title_tests { use crate::model::repository::Tag; use crate::repository::open_connection; - use super::super::super::repository::{create_tag, get_tag_by_title}; + use crate::tags::repository::{create_tag, get_tag_by_title}; use crate::test::*; #[test] @@ -57,7 +57,7 @@ mod get_tag_by_title_tests { mod get_tag_by_id_tests { use crate::model::repository::Tag; use crate::repository::open_connection; - use super::super::super::repository::{create_tag, get_tag}; + use crate::tags::repository::{create_tag, get_tag}; use crate::test::{cleanup, init_db_folder}; #[test] @@ -81,7 +81,7 @@ mod get_tag_by_id_tests { mod update_tag_tests { use crate::model::repository::Tag; use crate::repository::open_connection; - use super::super::super::repository::{create_tag, get_tag, update_tag}; + use crate::tags::repository::{create_tag, get_tag, update_tag}; use crate::test::{cleanup, init_db_folder}; #[test] @@ -112,7 +112,7 @@ mod update_tag_tests { mod delete_tag_tests { use crate::repository::open_connection; - use super::super::super::repository::{create_tag, delete_tag, get_tag}; + use crate::tags::repository::{create_tag, delete_tag, get_tag}; use crate::test::{cleanup, init_db_folder}; #[test] @@ -129,7 +129,7 @@ mod delete_tag_tests { } mod get_tag_on_file_tests { - use super::super::super::repository::*; + use crate::tags::repository::*; use crate::model::file_types::FileTypes; use crate::model::repository::{FileRecord, Tag}; use crate::repository::file_repository::create_file; @@ -197,7 +197,7 @@ mod get_tag_on_file_tests { } mod remove_tag_from_file_tests { - use super::super::super::repository::*; + use crate::tags::repository::*; use crate::model::file_types::FileTypes; use crate::model::repository::{FileRecord, Tag}; use crate::repository::file_repository::create_file; @@ -233,7 +233,7 @@ 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 super::super::super::repository::{add_tag_to_folder, create_tag, get_tags_on_folder}; + use crate::tags::repository::{add_tag_to_folder, create_tag, get_tags_on_folder}; use crate::test::*; #[test] @@ -294,7 +294,7 @@ 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 super::super::super::repository::{ + use crate::tags::repository::{ create_tag, get_tags_on_folder, remove_tag_from_folder, }; use crate::test::{cleanup, init_db_folder}; @@ -325,7 +325,7 @@ mod get_tags_on_files_tests { use std::collections::HashMap; use crate::{model::repository::Tag, repository::open_connection, test::*}; - use super::super::super::repository::get_tags_on_files; + use crate::tags::repository::get_tags_on_files; #[test] fn returns_proper_mapping_for_file_tags() { diff --git a/src/tags/tests/service.rs b/src/tags/tests/service.rs index 66f633c..c45a4d2 100644 --- a/src/tags/tests/service.rs +++ b/src/tags/tests/service.rs @@ -1,6 +1,6 @@ mod get_tag_tests { use crate::model::error::tag_errors::GetTagError; - use super::super::super::service::{create_tag, get_tag}; + use crate::tags::service::{create_tag, get_tag}; use crate::test::*; #[test] @@ -24,7 +24,7 @@ mod get_tag_tests { mod update_tag_tests { use crate::model::error::tag_errors::UpdateTagError; use crate::model::response::TagApi; - use super::super::super::service::{create_tag, get_tag, update_tag}; + use crate::tags::service::{create_tag, get_tag, update_tag}; use crate::test::{cleanup, init_db_folder}; #[test] @@ -71,7 +71,7 @@ mod update_tag_tests { mod delete_tag_tests { use crate::model::error::tag_errors::GetTagError; - use super::super::super::service::{create_tag, delete_tag, get_tag}; + use crate::tags::service::{create_tag, delete_tag, get_tag}; use crate::test::{cleanup, init_db_folder}; #[test] @@ -91,7 +91,7 @@ mod update_file_tag_test { use crate::model::repository::FileRecord; use crate::model::response::TagApi; - use super::super::super::service::{create_tag, get_tags_on_file, update_file_tags}; + use crate::tags::service::{create_tag, get_tags_on_file, update_file_tags}; use crate::test::{cleanup, init_db_folder, now}; #[test] @@ -294,7 +294,7 @@ mod update_folder_tag_test { use crate::model::repository::Folder; use crate::model::response::TagApi; use crate::repository::{folder_repository, open_connection}; - use super::super::super::service::{create_tag, get_tags_on_folder, update_folder_tags}; + use crate::tags::service::{create_tag, get_tags_on_folder, update_folder_tags}; use crate::test::{cleanup, init_db_folder}; #[test] @@ -504,7 +504,7 @@ mod update_folder_tag_test { mod get_tags_on_file_tests { use crate::model::error::tag_errors::TagRelationError; - use super::super::super::service::get_tags_on_file; + use crate::tags::service::get_tags_on_file; use crate::test::{cleanup, init_db_folder}; #[test] @@ -518,7 +518,7 @@ mod get_tags_on_file_tests { mod get_tags_on_folder_tests { use crate::model::error::tag_errors::TagRelationError; - use super::super::super::service::get_tags_on_folder; + use crate::tags::service::get_tags_on_folder; use crate::test::{cleanup, init_db_folder}; #[test] From 22d703eb7e751840422f93e659ff036249961d1e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Nov 2025 19:04:47 +0000 Subject: [PATCH 11/61] Move client() and set_password() helpers to shared test utilities Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- src/tags/tests/handler.rs | 16 ---------------- src/test/mod.rs | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/tags/tests/handler.rs b/src/tags/tests/handler.rs index 0ff5b09..bf4b0e5 100644 --- a/src/tags/tests/handler.rs +++ b/src/tags/tests/handler.rs @@ -1,25 +1,9 @@ use rocket::http::{Header, Status}; -use rocket::local::blocking::Client; use crate::model::response::TagApi; use crate::repository::initialize_db; -use crate::rocket; use crate::test::*; -fn client() -> Client { - Client::tracked(rocket()).unwrap() -} - -fn set_password() { - init_db_folder(); - let client = client(); - let uri = uri!("/api/password"); - client - .post(uri) - .body(r#"{"username":"username","password":"password"}"#) - .dispatch(); -} - #[test] fn get_tag_without_creds() { initialize_db().unwrap(); diff --git a/src/test/mod.rs b/src/test/mod.rs index 9cd14ec..2f92bdc 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -16,6 +16,7 @@ mod tests { use crate::service::file_service::{determine_file_type, file_dir}; 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; @@ -24,6 +25,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() From 2c06c6cc4a4b61bca3ce4d1f0f9308d7d7824a1e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Nov 2025 19:20:07 +0000 Subject: [PATCH 12/61] Organize handler tests by endpoint and use AUTH constant Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- src/tags/tests/handler.rs | 252 ++++++++++++++++++++------------------ 1 file changed, 134 insertions(+), 118 deletions(-) diff --git a/src/tags/tests/handler.rs b/src/tags/tests/handler.rs index bf4b0e5..5f8b43a 100644 --- a/src/tags/tests/handler.rs +++ b/src/tags/tests/handler.rs @@ -4,135 +4,151 @@ use crate::model::response::TagApi; use crate::repository::initialize_db; use crate::test::*; -#[test] -fn get_tag_without_creds() { - initialize_db().unwrap(); - let client = client(); - let res = client.get(uri!("/tags/1")).dispatch(); - assert_eq!(res.status(), Status::Unauthorized); - cleanup(); -} +mod get_tag_tests { + use super::*; -#[test] -fn get_tag_success() { - set_password(); - create_tag_db_entry("test_tag"); - let client = client(); - let auth = Header::new("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ="); - let res = client.get(uri!("/tags/1")).header(auth).dispatch(); - assert_eq!(res.status(), Status::Ok); - cleanup(); -} + #[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 get_tag_not_found() { - set_password(); - let client = client(); - let auth = Header::new("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ="); - let res = client.get(uri!("/tags/999")).header(auth).dispatch(); - assert_eq!(res.status(), Status::NotFound); - 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 create_tag_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 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(); + } } -#[test] -fn create_tag_success() { - set_password(); - let client = client(); - let auth = Header::new("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ="); - let res = client - .post(uri!("/tags")) - .header(auth) - .body(r#"{"title":"new_tag"}"#) - .dispatch(); - assert_eq!(res.status(), Status::Created); - cleanup(); -} +mod create_tag_tests { + use super::*; -#[test] -fn update_tag_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 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 update_tag_success() { - set_password(); - create_tag_db_entry("original_tag"); - let client = client(); - let auth = Header::new("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ="); - 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 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(); + } } -#[test] -fn update_tag_not_found() { - set_password(); - let client = client(); - let auth = Header::new("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ="); - let res = client - .put(uri!("/tags")) - .header(auth) - .body(r#"{"id":999,"title":"updated_tag"}"#) - .dispatch(); - assert_eq!(res.status(), Status::NotFound); - cleanup(); -} +mod update_tag_tests { + use super::*; -#[test] -fn update_tag_already_exists() { - set_password(); - create_tag_db_entry("tag1"); - create_tag_db_entry("tag2"); - let client = client(); - let auth = Header::new("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ="); - let res = client - .put(uri!("/tags")) - .header(auth) - .body(r#"{"id":2,"title":"tag1"}"#) - .dispatch(); - assert_eq!(res.status(), Status::BadRequest); - cleanup(); -} + #[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 delete_tag_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 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(); + } } -#[test] -fn delete_tag_success() { - set_password(); - create_tag_db_entry("test_tag"); - let client = client(); - let auth = Header::new("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ="); - let res = client.delete(uri!("/tags/1")).header(auth).dispatch(); - assert_eq!(res.status(), Status::NoContent); - 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(); + } } From d2a2d80ae0adedbb05dd3812da9b522febc4e9b3 Mon Sep 17 00:00:00 2001 From: ploiu Date: Sat, 15 Nov 2025 21:38:32 +0000 Subject: [PATCH 13/61] no longer use old tag tables --- src/assets/migration/v6.sql | 3 ++ .../queries/file/get_files_by_all_tags.sql | 4 +-- .../queries/folder/get_folders_by_any_tag.sql | 19 ++++++---- .../folder/get_parent_folders_with_tags.sql | 35 ++++++++++++------- src/assets/queries/tags/add_tag_to_file.sql | 6 ++-- src/assets/queries/tags/add_tag_to_folder.sql | 6 ++-- src/assets/queries/tags/get_tags_for_file.sql | 12 ++++--- .../queries/tags/get_tags_for_files.sql | 13 ++++--- .../queries/tags/get_tags_for_folder.sql | 12 ++++--- .../queries/tags/remove_tag_from_file.sql | 10 ++++-- .../queries/tags/remove_tag_from_folder.sql | 10 ++++-- src/service/folder_service.rs | 2 +- src/tags/tests/handler.rs | 1 - src/tags/tests/repository.rs | 10 +++--- src/test/mod.rs | 4 +-- 15 files changed, 94 insertions(+), 53 deletions(-) diff --git a/src/assets/migration/v6.sql b/src/assets/migration/v6.sql index cacd95d..b5f2e76 100644 --- a/src/assets/migration/v6.sql +++ b/src/assets/migration/v6.sql @@ -136,5 +136,8 @@ 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_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_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_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_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_tags_for_file.sql b/src/assets/queries/tags/get_tags_for_file.sql index 3e88719..2048112 100644 --- a/src/assets/queries/tags/get_tags_for_file.sql +++ b/src/assets/queries/tags/get_tags_for_file.sql @@ -1,4 +1,8 @@ -select Tags.id, Tags.title -from Tags - join Files_Tags on Tags.id = Files_Tags.tagId -where Files_Tags.fileRecordId = ?1; +select + t.*, + ti.inheritedFromId +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_tags_for_files.sql b/src/assets/queries/tags/get_tags_for_files.sql index ab91303..e05fa9b 100644 --- a/src/assets/queries/tags/get_tags_for_files.sql +++ b/src/assets/queries/tags/get_tags_for_files.sql @@ -1,4 +1,9 @@ -select Files_Tags.fileRecordId, Tags.id, Tags.title -from Tags - join Files_Tags on Tags.id = Files_Tags.tagId -where Files_Tags.fileRecordId in ({}); +select + ti.fileId, + t.id, + t.title +from + TaggedItems ti + join Tags t on ti.tagId = t.id +where + ti.fileId in ({ }) \ No newline at end of file diff --git a/src/assets/queries/tags/get_tags_for_folder.sql b/src/assets/queries/tags/get_tags_for_folder.sql index f92eb70..7a6a4e3 100644 --- a/src/assets/queries/tags/get_tags_for_folder.sql +++ b/src/assets/queries/tags/get_tags_for_folder.sql @@ -1,4 +1,8 @@ -select Tags.id, Tags.title -from Tags - join Folders_Tags on Tags.id = Folders_Tags.tagId -where Folders_Tags.folderId = ?1; +select + t.*, + ti.inheritedFromId +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/remove_tag_from_file.sql b/src/assets/queries/tags/remove_tag_from_file.sql index decfe04..b5029bc 100644 --- a/src/assets/queries/tags/remove_tag_from_file.sql +++ b/src/assets/queries/tags/remove_tag_from_file.sql @@ -1,3 +1,7 @@ -delete from Files_Tags -where fileRecordId = ?1 -and tagId = ?2 +-- removes a single non-inherited tag from a file +delete from + TaggedItems +where + fileId = ?1 + and tagId = ?2 + and inheritedFromId is null; \ No newline at end of file diff --git a/src/assets/queries/tags/remove_tag_from_folder.sql b/src/assets/queries/tags/remove_tag_from_folder.sql index 0ba1b9e..071378a 100644 --- a/src/assets/queries/tags/remove_tag_from_folder.sql +++ b/src/assets/queries/tags/remove_tag_from_folder.sql @@ -1,3 +1,7 @@ -delete from Folders_Tags -where folderId = ?1 -and tagId = ?2 +-- removes a single non-inherited tag from a folder +delete from + TaggedItems +where + folderId = ?1 + and tagId = ?2 + and inheritedFromId is null; \ No newline at end of file diff --git a/src/service/folder_service.rs b/src/service/folder_service.rs index 092aa6e..3cff321 100644 --- a/src/service/folder_service.rs +++ b/src/service/folder_service.rs @@ -22,8 +22,8 @@ use crate::model::response::TagApi; use crate::model::response::folder_responses::FolderResponse; use crate::previews; use crate::repository::{folder_repository, open_connection}; -use crate::service::file_service::{check_root_dir, file_dir}; use crate::service::file_service; +use crate::service::file_service::{check_root_dir, file_dir}; use crate::tags::repository as tag_repository; use crate::tags::service as tag_service; use crate::{model, repository}; diff --git a/src/tags/tests/handler.rs b/src/tags/tests/handler.rs index 5f8b43a..58c32da 100644 --- a/src/tags/tests/handler.rs +++ b/src/tags/tests/handler.rs @@ -1,6 +1,5 @@ use rocket::http::{Header, Status}; -use crate::model::response::TagApi; use crate::repository::initialize_db; use crate::test::*; diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index b5ea262..6f89c1b 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -129,11 +129,11 @@ mod delete_tag_tests { } mod get_tag_on_file_tests { - use crate::tags::repository::*; 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::tags::repository::*; use crate::test::*; #[test] @@ -197,11 +197,11 @@ mod get_tag_on_file_tests { } mod remove_tag_from_file_tests { - use crate::tags::repository::*; 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::tags::repository::*; use crate::test::{cleanup, init_db_folder, now}; #[test] @@ -294,9 +294,7 @@ 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::tags::repository::{ - create_tag, get_tags_on_folder, remove_tag_from_folder, - }; + use crate::tags::repository::{create_tag, get_tags_on_folder, remove_tag_from_folder}; use crate::test::{cleanup, init_db_folder}; #[test] @@ -324,8 +322,8 @@ mod remove_tag_from_folder_tests { mod get_tags_on_files_tests { use std::collections::HashMap; - use crate::{model::repository::Tag, repository::open_connection, test::*}; use crate::tags::repository::get_tags_on_files; + use crate::{model::repository::Tag, repository::open_connection, test::*}; #[test] fn returns_proper_mapping_for_file_tags() { diff --git a/src/test/mod.rs b/src/test/mod.rs index 2f92bdc..f3106ae 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -10,9 +10,7 @@ mod tests { use crate::model::api::FileApi; use crate::model::repository::{FileRecord, Folder, Tag}; use crate::previews; - use crate::repository::{ - file_repository, folder_repository, initialize_db, open_connection, - }; + use crate::repository::{file_repository, folder_repository, initialize_db, open_connection}; use crate::service::file_service::{determine_file_type, file_dir}; use crate::tags::repository as tag_repository; use crate::temp_dir; From 910b4d1dd664def15a43190246fbfad7f4c31709 Mon Sep 17 00:00:00 2001 From: ploiu Date: Sat, 15 Nov 2025 22:25:04 +0000 Subject: [PATCH 14/61] simplify tag search --- src/handler/file_handler.rs | 3 - src/model/error/file_errors.rs | 3 - src/repository/folder_repository.rs | 190 +--------- src/service/folder_service.rs | 519 +--------------------------- src/service/search_service.rs | 235 +------------ 5 files changed, 8 insertions(+), 942 deletions(-) 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/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/repository/folder_repository.rs b/src/repository/folder_repository.rs index 4b4f358..ad9031c 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}; @@ -209,73 +209,6 @@ 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( - folder_id: u32, - con: &Connection, -) -> Result, rusqlite::Error> { - let mut pst = con.prepare(include_str!( - "../assets/queries/folder/get_parent_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)?); - } - Ok(ids) -} - fn map_folder(row: &rusqlite::Row) -> Result { let id: Option = row.get(0)?; let name: String = row.get(1)?; @@ -320,76 +253,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; @@ -441,54 +304,3 @@ mod get_child_files_tests { cleanup(); } } - -#[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}, - }; - - #[test] - fn returns_all_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]; - let con = open_connection(); - let actual = get_ancestor_folder_ids(5, &con).unwrap(); - con.close().unwrap(); - assert_eq!(actual, expected); - cleanup(); - } - - #[test] - fn does_not_return_non_parents() { - 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); - cleanup(); - } - - #[test] - fn does_not_panic_when_no_parents() { - init_db_folder(); - let con = open_connection(); - create_folder_db_entry("test", None); - let res = get_ancestor_folder_ids(1, &con); - con.close().unwrap(); - res.expect("no error should be returned if the folder does not have a parent"); - cleanup(); - } -} diff --git a/src/service/folder_service.rs b/src/service/folder_service.rs index 3cff321..7eb6b87 100644 --- a/src/service/folder_service.rs +++ b/src/service/folder_service.rs @@ -1,7 +1,6 @@ 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 regex::Regex; @@ -198,109 +197,6 @@ 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, @@ -384,73 +280,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 }; @@ -760,12 +589,6 @@ 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; @@ -911,346 +734,6 @@ mod update_folder_tests { } } -#[cfg(test)] -mod reduce_folders_by_tag_tests { - use std::collections::HashSet; - - 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, - }; - - #[test] - fn reduce_folders_by_tag_works() { - 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(), - }], - }, - ]); - - 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()]), - ) - .unwrap() - .into_iter() - .map(|f| f.id) - .collect::>(); - assert_eq!(expected, actual); - cleanup(); - } - - #[test] - fn reduce_folders_by_tag_keeps_first_folder_with_all_tags() { - 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); - cleanup(); - } -} - #[cfg(test)] mod download_folder_tests { use crate::{ diff --git a/src/service/search_service.rs b/src/service/search_service.rs index d784c8c..63fd2d1 100644 --- a/src/service/search_service.rs +++ b/src/service/search_service.rs @@ -1,17 +1,13 @@ 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}; -use crate::service::folder_service; +use crate::repository::{file_repository, open_connection}; use crate::tags::repository as tag_repository; pub fn search_files( @@ -129,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( @@ -345,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; From bf605e6bceeaa38b2ce0f568f602943a44fd494e Mon Sep 17 00:00:00 2001 From: ploiu Date: Sun, 16 Nov 2025 14:21:45 +0000 Subject: [PATCH 15/61] start of using TaggedItem in the codebase --- src/assets/migration/v6.sql | 213 ++++++++++++------ src/assets/queries/tags/get_tags_for_file.sql | 2 +- .../queries/tags/get_tags_for_folder.sql | 2 +- .../queries/tags/remove_tag_from_file.sql | 2 +- .../queries/tags/remove_tag_from_folder.sql | 2 +- src/model/repository/mod.rs | 15 ++ src/tags/repository.rs | 23 +- src/tags/service.rs | 8 +- src/tags/tests/repository.rs | 10 +- src/test/mod.rs | 14 +- 10 files changed, 198 insertions(+), 93 deletions(-) diff --git a/src/assets/migration/v6.sql b/src/assets/migration/v6.sql index b5f2e76..881dcae 100644 --- a/src/assets/migration/v6.sql +++ b/src/assets/migration/v6.sql @@ -7,7 +7,7 @@ create table TaggedItems ( 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 - inheritedFromId integer references Folders(id) on delete cascade default null, + impliedFromId 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)) ); @@ -48,96 +48,169 @@ from 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 +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 + 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 + 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, inheritedFromId) -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 -); + 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, impliedFromId) +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 ( +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 + 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 + 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 - ) + 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, inheritedFromId) -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 -); +insert into + TaggedItems(tagId, fileId, impliedFromId) +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'; +update + metadata +set + value = 6 +where + name = 'version'; + commit; \ 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 index 2048112..311529a 100644 --- a/src/assets/queries/tags/get_tags_for_file.sql +++ b/src/assets/queries/tags/get_tags_for_file.sql @@ -1,6 +1,6 @@ select t.*, - ti.inheritedFromId + ti.impliedFromId from Tags t join TaggedItems ti on t.id = ti.tagId diff --git a/src/assets/queries/tags/get_tags_for_folder.sql b/src/assets/queries/tags/get_tags_for_folder.sql index 7a6a4e3..70baa4e 100644 --- a/src/assets/queries/tags/get_tags_for_folder.sql +++ b/src/assets/queries/tags/get_tags_for_folder.sql @@ -1,6 +1,6 @@ select t.*, - ti.inheritedFromId + ti.impliedFromId from Tags t join TaggedItems ti on t.id = ti.tagId diff --git a/src/assets/queries/tags/remove_tag_from_file.sql b/src/assets/queries/tags/remove_tag_from_file.sql index b5029bc..8d8e253 100644 --- a/src/assets/queries/tags/remove_tag_from_file.sql +++ b/src/assets/queries/tags/remove_tag_from_file.sql @@ -4,4 +4,4 @@ delete from where fileId = ?1 and tagId = ?2 - and inheritedFromId is null; \ No newline at end of file + and impliedFromId is null; \ No newline at end of file diff --git a/src/assets/queries/tags/remove_tag_from_folder.sql b/src/assets/queries/tags/remove_tag_from_folder.sql index 071378a..38632d1 100644 --- a/src/assets/queries/tags/remove_tag_from_folder.sql +++ b/src/assets/queries/tags/remove_tag_from_folder.sql @@ -4,4 +4,4 @@ delete from where folderId = ?1 and tagId = ?2 - and inheritedFromId is null; \ No newline at end of file + and impliedFromId is null; \ No newline at end of file diff --git a/src/model/repository/mod.rs b/src/model/repository/mod.rs index 82b7f70..36e5b30 100644 --- a/src/model/repository/mod.rs +++ b/src/model/repository/mod.rs @@ -40,6 +40,21 @@ pub struct 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. [`implied_from_id`] will be None if the tag is explicitly on the item +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 implied_from_id: Option, +} + impl From<&FileApi> for FileRecord { fn from(value: &FileApi) -> Self { let create_date = value diff --git a/src/tags/repository.rs b/src/tags/repository.rs index f380b78..8cbaa44 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -40,6 +40,15 @@ pub fn get_tag_by_title( } } +/// 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(repository::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) @@ -59,7 +68,11 @@ pub fn delete_tag(id: u32, con: &Connection) -> Result<(), rusqlite::Error> { } /// 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> { +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(()) @@ -68,7 +81,7 @@ pub fn add_tag_to_file(file_id: u32, tag_id: u32, con: &Connection) -> Result<() pub fn get_tags_on_file( file_id: u32, con: &Connection, -) -> Result, rusqlite::Error> { +) -> 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(); @@ -81,7 +94,7 @@ pub fn get_tags_on_file( pub fn get_tags_on_files( file_ids: Vec, con: &Connection, -) -> Result>, rusqlite::Error> { +) -> Result>, rusqlite::Error> { struct TagFile { file_id: u32, tag_id: u32, @@ -134,7 +147,7 @@ pub fn remove_tag_from_file( Ok(()) } -pub fn add_tag_to_folder( +pub fn add_explicit_tag_to_folder( folder_id: u32, tag_id: u32, con: &Connection, @@ -147,7 +160,7 @@ pub fn add_tag_to_folder( pub fn get_tags_on_folder( folder_id: u32, con: &Connection, -) -> Result, rusqlite::Error> { +) -> Result, rusqlite::Error> { let mut pst = con.prepare(include_str!( "../assets/queries/tags/get_tags_for_folder.sql" ))?; diff --git a/src/tags/service.rs b/src/tags/service.rs index 8c09032..8a9f2ab 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -219,7 +219,7 @@ pub fn update_file_tags(file_id: u32, tags: Vec) -> Result<(), TagRelati if added_tag_ids.contains(&tag_id) { continue; } - if let Err(e) = tag_repository::add_tag_to_file(file_id, tag_id, &con) { + if let Err(e) = tag_repository::add_explicit_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() @@ -245,7 +245,7 @@ pub fn update_file_tags(file_id: u32, tags: Vec) -> Result<(), TagRelati if added_tag_ids.contains(&tag_id) { continue; } - if let Err(e) = tag_repository::add_tag_to_file(file_id, tag_id, &con) { + if let Err(e) = tag_repository::add_explicit_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() @@ -311,7 +311,7 @@ pub fn update_folder_tags(folder_id: u32, tags: Vec) -> Result<(), TagRe if added_tag_ids.contains(&tag_id) { continue; } - if let Err(e) = tag_repository::add_tag_to_folder(folder_id, tag_id, &con) { + 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() @@ -341,7 +341,7 @@ pub fn update_folder_tags(folder_id: u32, tags: Vec) -> Result<(), TagRe if added_tag_ids.contains(&tag_id) { continue; } - if let Err(e) = tag_repository::add_tag_to_folder(folder_id, tag_id, &con) { + 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() diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index 6f89c1b..93782cf 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -154,8 +154,8 @@ mod get_tag_on_file_tests { &con, ) .unwrap(); - add_tag_to_file(1, 1, &con).unwrap(); - add_tag_to_file(1, 2, &con).unwrap(); + add_explicit_tag_to_file(1, 1, &con).unwrap(); + add_explicit_tag_to_file(1, 2, &con).unwrap(); let res = get_tags_on_file(1, &con).unwrap(); con.close().unwrap(); assert_eq!( @@ -233,7 +233,7 @@ 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::tags::repository::{add_tag_to_folder, create_tag, get_tags_on_folder}; + use crate::tags::repository::{add_explicit_tag_to_folder, create_tag, get_tags_on_folder}; use crate::test::*; #[test] @@ -251,8 +251,8 @@ mod get_tag_on_folder_tests { &con, ) .unwrap(); - add_tag_to_folder(1, 1, &con).unwrap(); - add_tag_to_folder(1, 2, &con).unwrap(); + add_explicit_tag_to_folder(1, 1, &con).unwrap(); + add_explicit_tag_to_folder(1, 2, &con).unwrap(); let res = get_tags_on_folder(1, &con).unwrap(); con.close().unwrap(); assert_eq!( diff --git a/src/test/mod.rs b/src/test/mod.rs index f3106ae..e157781 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -125,15 +125,19 @@ 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 inherit_tag_folder(name: &str, folder_id: u32, inherited_from: u32) { + let con = open_connection(); + } + 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(); } @@ -141,7 +145,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(); } @@ -149,7 +153,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(); } @@ -240,7 +244,7 @@ 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_repository::add_explicit_tag_to_file(file_id, id, &con).unwrap(); tag.id = Some(id); } if let Some(folder_id) = self.folder_id { From 970b7db0206aad8514b0805b73d2250177720e18 Mon Sep 17 00:00:00 2001 From: ploiu Date: Mon, 17 Nov 2025 00:58:48 +0000 Subject: [PATCH 16/61] more progress on tag changes --- src/assets/migration/v6.sql | 6 +- src/assets/queries/tags/get_tags_for_file.sql | 8 +- .../queries/tags/get_tags_for_files.sql | 7 +- .../queries/tags/get_tags_for_folder.sql | 8 +- ....sql => remove_explicit_tag_from_file.sql} | 2 +- .../queries/tags/remove_tag_from_folder.sql | 2 +- src/model/api.rs | 18 ++-- src/model/repository/mod.rs | 10 +- src/model/response/mod.rs | 26 ++++++ src/tags/repository.rs | 81 +++++++++-------- src/tags/service.rs | 91 ++++++++----------- src/tags/tests/repository.rs | 2 +- 12 files changed, 144 insertions(+), 117 deletions(-) rename src/assets/queries/tags/{remove_tag_from_file.sql => remove_explicit_tag_from_file.sql} (79%) diff --git a/src/assets/migration/v6.sql b/src/assets/migration/v6.sql index 881dcae..cde5ec6 100644 --- a/src/assets/migration/v6.sql +++ b/src/assets/migration/v6.sql @@ -7,7 +7,7 @@ create table TaggedItems ( 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 - impliedFromId integer references Folders(id) on delete cascade default null, + 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)) ); @@ -107,7 +107,7 @@ nearestTags as ( ) ) -- now that we have our functions, we can invoke nearestTags to get all the inherited tags and insert them insert into - TaggedItems(tagId, folderId, impliedFromId) + TaggedItems(tagId, folderId, implicitFromId) select n.tagId, n.folderId, @@ -184,7 +184,7 @@ nearestTags as ( ) ) insert into - TaggedItems(tagId, fileId, impliedFromId) + TaggedItems(tagId, fileId, implicitFromId) select n.tagId, n.fileId, diff --git a/src/assets/queries/tags/get_tags_for_file.sql b/src/assets/queries/tags/get_tags_for_file.sql index 311529a..77a397a 100644 --- a/src/assets/queries/tags/get_tags_for_file.sql +++ b/src/assets/queries/tags/get_tags_for_file.sql @@ -1,6 +1,10 @@ select - t.*, - ti.impliedFromId + ti.id, + ti.fileId, + ti.folderId, + ti.implicitFromId, + t.id, + t.title from Tags t join TaggedItems ti on t.id = ti.tagId diff --git a/src/assets/queries/tags/get_tags_for_files.sql b/src/assets/queries/tags/get_tags_for_files.sql index e05fa9b..40c1924 100644 --- a/src/assets/queries/tags/get_tags_for_files.sql +++ b/src/assets/queries/tags/get_tags_for_files.sql @@ -1,9 +1,12 @@ select + ti.id, ti.fileId, + ti.folderId, + ti.implicitFromId, t.id, t.title from - TaggedItems ti - join Tags t on ti.tagId = t.id + 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_tags_for_folder.sql b/src/assets/queries/tags/get_tags_for_folder.sql index 70baa4e..05f2bd8 100644 --- a/src/assets/queries/tags/get_tags_for_folder.sql +++ b/src/assets/queries/tags/get_tags_for_folder.sql @@ -1,6 +1,10 @@ select - t.*, - ti.impliedFromId + ti.id, + ti.fileId, + ti.folderId, + ti.implicitFromId, + t.id, + t.title from Tags t join TaggedItems ti on t.id = ti.tagId diff --git a/src/assets/queries/tags/remove_tag_from_file.sql b/src/assets/queries/tags/remove_explicit_tag_from_file.sql similarity index 79% rename from src/assets/queries/tags/remove_tag_from_file.sql rename to src/assets/queries/tags/remove_explicit_tag_from_file.sql index 8d8e253..805f5a5 100644 --- a/src/assets/queries/tags/remove_tag_from_file.sql +++ b/src/assets/queries/tags/remove_explicit_tag_from_file.sql @@ -4,4 +4,4 @@ delete from where fileId = ?1 and tagId = ?2 - and impliedFromId is null; \ No newline at end of file + and implicitFromId is null; \ No newline at end of file diff --git a/src/assets/queries/tags/remove_tag_from_folder.sql b/src/assets/queries/tags/remove_tag_from_folder.sql index 38632d1..764f775 100644 --- a/src/assets/queries/tags/remove_tag_from_folder.sql +++ b/src/assets/queries/tags/remove_tag_from_folder.sql @@ -4,4 +4,4 @@ delete from where folderId = ?1 and tagId = ?2 - and impliedFromId is null; \ No newline at end of file + and implicitFromId is null; \ No newline at end of file diff --git a/src/model/api.rs b/src/model/api.rs index ebe3ec7..640e3db 100644 --- a/src/model/api.rs +++ b/src/model/api.rs @@ -4,7 +4,7 @@ use rocket::serde::{Deserialize, Serialize}; use crate::model::file_types::FileTypes; use crate::model::repository::FileRecord; -use crate::model::response::TagApi; +use crate::model::response::{TagApi, TaggedItemApi}; #[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Hash, Clone)] #[serde(crate = "rocket::serde")] @@ -24,7 +24,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 +35,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 diff --git a/src/model/repository/mod.rs b/src/model/repository/mod.rs index 36e5b30..f7987be 100644 --- a/src/model/repository/mod.rs +++ b/src/model/repository/mod.rs @@ -32,6 +32,7 @@ pub struct Folder { pub parent_id: Option, } +/// 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 @@ -43,7 +44,8 @@ pub struct Tag { /// 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. [`implied_from_id`] will be None if the tag is explicitly on the item +/// [`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, @@ -52,7 +54,11 @@ pub struct TaggedItem { /// 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 implied_from_id: Option, + pub implicit_from_id: Option, + /// the tag's title + pub title: String, + /// the id of the actual tag + pub tag_id: u32, } impl From<&FileApi> for FileRecord { diff --git a/src/model/response/mod.rs b/src/model/response/mod.rs index c30a50a..e9bcce7 100644 --- a/src/model/response/mod.rs +++ b/src/model/response/mod.rs @@ -25,6 +25,22 @@ 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 + pub 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 { @@ -57,3 +73,13 @@ impl From for TagApi { } } } + +impl From for TaggedItemApi { + fn from(value: repository::TaggedItem) -> Self { + Self { + id: Some(value.tag_id), + title: value.title, + implicit_from: value.implicit_from_id, + } + } +} diff --git a/src/tags/repository.rs b/src/tags/repository.rs index 8cbaa44..c923089 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -83,8 +83,8 @@ pub fn get_tags_on_file( 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(); + 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?); } @@ -107,41 +107,31 @@ pub fn get_tags_on_files( 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, - }); - } + 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) } -pub fn remove_tag_from_file( +/// 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_tag_from_file.sql" + "../assets/queries/tags/remove_explicit_tag_from_file.sql" ))?; pst.execute(rusqlite::params![file_id, tag_id])?; Ok(()) @@ -164,14 +154,8 @@ pub fn get_tags_on_folder( 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) + let rows = pst.query_map(rusqlite::params![folder_id], tagged_item_mapper)?; + rows.collect::, rusqlite::Error>>() } pub fn remove_tag_from_folder( @@ -186,8 +170,33 @@ pub fn remove_tag_from_folder( Ok(()) } +/// maps a [`repository::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(repository::Tag { id, title }) } + +/// 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(repository::TaggedItem { + id, + file_id, + folder_id, + implicit_from_id, + tag_id, + title, + }) +} diff --git a/src/tags/service.rs b/src/tags/service.rs index 8a9f2ab..65da0e1 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -1,12 +1,14 @@ use std::backtrace::Backtrace; use std::collections::HashSet; +use itertools::Itertools; + 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::model::repository::{self, TaggedItem}; +use crate::model::response::{TagApi, TaggedItemApi}; use crate::repository::open_connection; use crate::service::{file_service, folder_service}; use crate::tags::repository as tag_repository; @@ -167,6 +169,8 @@ pub fn delete_tag(id: u32) -> Result<(), DeleteTagError> { /// Updates the tags on a file by replacing all existing tags with the provided list. /// +/// Only explict tags can be managed this way. +/// /// This function will: /// 1. Remove all existing tags from the file /// 2. Add tags that already exist in the database (those with an `id`) @@ -184,7 +188,7 @@ pub fn delete_tag(id: u32) -> Result<(), DeleteTagError> { /// - `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> { +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!( @@ -193,70 +197,49 @@ pub fn update_file_tags(file_id: u32, tags: Vec) -> Result<(), TagRelati ); 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() { + 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)?.into_iter()); + let tags = HashSet::from_iter(tags.into_iter()); + // 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_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_explicit_tag_to_file(file_id, tag_id, &con) { + if let Err(e) = + tag_repository::remove_explicit_tag_from_file(file_id, tag.id.unwrap(), &con) + { log::error!( - "Failed to add tag to file with id {file_id}! Error is {e:?}\n{}", + "Failed to remove tags from 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()) { + for tag in tags_to_add { + let created = match create_tag(tag.title.clone()) { Ok(t) => t, - Err(_) => { + Err(e) => { con.close().unwrap(); + log::error!( + "Failed to create tag! Error is {e:?}\n{}", + Backtrace::force_capture() + ); 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_file(file_id, tag_id, &con) { + 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 with id {file_id}! Error is {e:?}\n{}", - Backtrace::force_capture() + "Failed to add tag to file: {e:?}\n{}", + Backtrace::force_capture(), ); - con.close().unwrap(); return Err(TagRelationError::DbError); } - added_tag_ids.insert(tag_id); } - - con.close().unwrap(); Ok(()) } @@ -357,7 +340,7 @@ pub fn update_folder_tags(folder_id: u32, tags: Vec) -> Result<(), TagRe } /// retrieves all the tags on the file with the passed id -pub fn get_tags_on_file(file_id: u32) -> Result, TagRelationError> { +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!( @@ -379,13 +362,12 @@ pub fn get_tags_on_file(file_id: u32) -> Result, TagRelationError> { } }; con.close().unwrap(); - let api_tags: Vec = file_tags.into_iter().map(TagApi::from).collect(); - Ok(api_tags) + 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> { +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!( @@ -407,6 +389,5 @@ pub fn get_tags_on_folder(folder_id: u32) -> Result, TagRelationErro } }; con.close().unwrap(); - let api_tags: Vec = db_tags.into_iter().map(TagApi::from).collect(); - Ok(api_tags) + Ok(db_tags.into_iter().map(TaggedItemApi::from).collect()) } diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index 93782cf..be1231e 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -221,7 +221,7 @@ mod remove_tag_from_file_tests { &con, ) .unwrap(); - remove_tag_from_file(1, 1, &con).unwrap(); + remove_explicit_tag_from_file(1, 1, &con).unwrap(); let tags = get_tags_on_file(1, &con).unwrap(); con.close().unwrap(); assert_eq!(Vec::::new(), tags); From f0973ec0ee6ed4fba0675c213b0bc9fb0eb61e9a Mon Sep 17 00:00:00 2001 From: ploiu Date: Wed, 19 Nov 2025 02:07:42 +0000 Subject: [PATCH 17/61] get the code to compile --- src/model/api.rs | 12 +-- src/model/request/folder_requests.rs | 4 +- src/model/response/folder_responses.rs | 8 +- src/model/response/mod.rs | 5 +- src/repository/file_repository.rs | 1 + src/service/file_service.rs | 12 ++- src/service/folder_service.rs | 31 +++--- src/service/search_service.rs | 75 ++++++++++---- src/tags/repository.rs | 5 - src/tags/service.rs | 22 ++-- src/tags/tests/repository.rs | 59 +++++++---- src/tags/tests/service.rs | 136 +++++++++++++++---------- src/test/mod.rs | 13 ++- 13 files changed, 236 insertions(+), 147 deletions(-) diff --git a/src/model/api.rs b/src/model/api.rs index 640e3db..787985a 100644 --- a/src/model/api.rs +++ b/src/model/api.rs @@ -4,7 +4,7 @@ use rocket::serde::{Deserialize, Serialize}; use crate::model::file_types::FileTypes; use crate::model::repository::FileRecord; -use crate::model::response::{TagApi, TaggedItemApi}; +use crate::model::response::TaggedItemApi; #[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Hash, Clone)] #[serde(crate = "rocket::serde")] @@ -66,7 +66,6 @@ impl FileApi { tags: Vec::new(), size: None, date_created: None, - // TODO file_types file_type: None, } } @@ -118,15 +117,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/request/folder_requests.rs b/src/model/request/folder_requests.rs index 46362ee..60772ae 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")] @@ -17,5 +17,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 e9bcce7..d31fb20 100644 --- a/src/model/response/mod.rs +++ b/src/model/response/mod.rs @@ -33,7 +33,8 @@ pub struct TagApi { #[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 - pub id: Option, + #[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 @@ -77,7 +78,7 @@ impl From for TagApi { impl From for TaggedItemApi { fn from(value: repository::TaggedItem) -> Self { Self { - id: Some(value.tag_id), + tag_id: Some(value.tag_id), title: value.title, implicit_from: value.implicit_from_id, } diff --git a/src/repository/file_repository.rs b/src/repository/file_repository.rs index 05dbf21..819dbf7 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() diff --git a/src/service/file_service.rs b/src/service/file_service.rs index 1c6b47e..8aa102b 100644 --- a/src/service/file_service.rs +++ b/src/service/file_service.rs @@ -687,7 +687,7 @@ 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::service::file_service::{file_dir, get_file_metadata, update_file}; use crate::service::folder_service; @@ -705,9 +705,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()), @@ -720,9 +721,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)); diff --git a/src/service/folder_service.rs b/src/service/folder_service.rs index 7eb6b87..1d0cf3c 100644 --- a/src/service/folder_service.rs +++ b/src/service/folder_service.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use std::fs::{self, File}; use std::path::Path; +use itertools::Itertools; use regex::Regex; use rusqlite::Connection; @@ -15,9 +16,8 @@ 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}; @@ -49,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 = + 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(), + Ok(t) => t.into_iter().map_into().collect(), Err(e) => { log::error!( "Failed to retrieve tags for folder. Exception is {e:?}\n{}", @@ -538,12 +538,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) @@ -592,7 +592,7 @@ fn delete_folder_recursively(id: u32, con: &Connection) -> Result, con: &Connection, ) -> Result, SearchFileError> { - let retrieved = file_repository::search_files_by_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) => { @@ -168,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] @@ -217,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, } ] ); @@ -249,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)); @@ -269,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) @@ -287,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) @@ -314,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()], @@ -337,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/tags/repository.rs b/src/tags/repository.rs index c923089..8900f9b 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -95,11 +95,6 @@ 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!( diff --git a/src/tags/service.rs b/src/tags/service.rs index 65da0e1..eecce49 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -7,7 +7,7 @@ use crate::model::error::file_errors::GetFileError; use crate::model::error::tag_errors::{ CreateTagError, DeleteTagError, GetTagError, TagRelationError, UpdateTagError, }; -use crate::model::repository::{self, TaggedItem}; +use crate::model::repository::{self}; use crate::model::response::{TagApi, TaggedItemApi}; use crate::repository::open_connection; use crate::service::{file_service, folder_service}; @@ -200,15 +200,15 @@ pub fn update_file_tags(file_id: u32, tags: Vec) -> Result<(), Ta 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)?.into_iter()); - let tags = HashSet::from_iter(tags.into_iter()); + 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.id.unwrap(), &con) + 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{}", @@ -262,7 +262,10 @@ pub fn update_file_tags(file_id: u32, tags: Vec) -> Result<(), Ta /// - `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> { +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}!"); @@ -273,7 +276,8 @@ pub fn update_folder_tags(folder_id: u32, tags: Vec) -> Result<(), TagRe // 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) { + if let Err(e) = tag_repository::remove_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() @@ -287,9 +291,9 @@ pub fn update_folder_tags(folder_id: u32, tags: Vec) -> Result<(), TagRe 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(); + let existing_tags: Vec<&TaggedItemApi> = tags.iter().filter(|t| t.tag_id.is_some()).collect(); for tag in existing_tags { - let tag_id = tag.id.unwrap(); + let tag_id = tag.tag_id.unwrap(); // Skip if we've already added this tag if added_tag_ids.contains(&tag_id) { continue; @@ -306,7 +310,7 @@ pub fn update_folder_tags(folder_id: u32, tags: Vec) -> Result<(), TagRe } // Then, create and add new tags (those without an id) - let new_tags: Vec<&TagApi> = tags.iter().filter(|t| t.id.is_none()).collect(); + 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, diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index be1231e..c82bbd1 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -130,7 +130,7 @@ mod delete_tag_tests { mod get_tag_on_file_tests { use crate::model::file_types::FileTypes; - use crate::model::repository::{FileRecord, Tag}; + use crate::model::repository::{FileRecord, TaggedItem}; use crate::repository::file_repository::create_file; use crate::repository::open_connection; use crate::tags::repository::*; @@ -160,13 +160,21 @@ mod get_tag_on_file_tests { con.close().unwrap(); assert_eq!( vec![ - Tag { + TaggedItem { id: 1, - title: "test".to_string() + tag_id: 1, + title: "test".to_string(), + file_id: Some(1), + folder_id: None, + implicit_from_id: None }, - Tag { + TaggedItem { id: 2, - title: "test2".to_string() + tag_id: 2, + title: "test2".to_string(), + file_id: Some(1), + folder_id: None, + implicit_from_id: None } ], res @@ -191,14 +199,14 @@ mod get_tag_on_file_tests { .unwrap(); let res = get_tags_on_file(1, &con).unwrap(); con.close().unwrap(); - assert_eq!(Vec::::new(), res); + assert_eq!(Vec::::new(), res); cleanup(); } } mod remove_tag_from_file_tests { use crate::model::file_types::FileTypes; - use crate::model::repository::{FileRecord, Tag}; + use crate::model::repository::{FileRecord, TaggedItem}; use crate::repository::file_repository::create_file; use crate::repository::open_connection; use crate::tags::repository::*; @@ -224,13 +232,13 @@ mod remove_tag_from_file_tests { remove_explicit_tag_from_file(1, 1, &con).unwrap(); let tags = get_tags_on_file(1, &con).unwrap(); con.close().unwrap(); - assert_eq!(Vec::::new(), tags); + assert_eq!(Vec::::new(), tags); cleanup(); } } mod get_tag_on_folder_tests { - use crate::model::repository::{Folder, Tag}; + use crate::model::repository::{Folder, TaggedItem}; use crate::repository::folder_repository::create_folder; use crate::repository::open_connection; use crate::tags::repository::{add_explicit_tag_to_folder, create_tag, get_tags_on_folder}; @@ -257,13 +265,21 @@ mod get_tag_on_folder_tests { con.close().unwrap(); assert_eq!( vec![ - Tag { + TaggedItem { id: 1, - title: "test".to_string() + tag_id: 1, + title: "test".to_string(), + folder_id: Some(1), + file_id: None, + implicit_from_id: None }, - Tag { + TaggedItem { id: 2, - title: "test2".to_string() + tag_id: 2, + title: "test2".to_string(), + folder_id: Some(1), + file_id: None, + implicit_from_id: None } ], res @@ -285,13 +301,13 @@ mod get_tag_on_folder_tests { .unwrap(); let res = get_tags_on_folder(1, &con).unwrap(); con.close().unwrap(); - assert_eq!(Vec::::new(), res); + assert_eq!(Vec::::new(), res); cleanup(); } } mod remove_tag_from_folder_tests { - use crate::model::repository::{Folder, Tag}; + use crate::model::repository::{Folder, TaggedItem}; use crate::repository::folder_repository::create_folder; use crate::repository::open_connection; use crate::tags::repository::{create_tag, get_tags_on_folder, remove_tag_from_folder}; @@ -314,7 +330,7 @@ mod remove_tag_from_folder_tests { 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); + assert_eq!(Vec::::new(), tags); cleanup(); } } @@ -322,8 +338,9 @@ mod remove_tag_from_folder_tests { mod get_tags_on_files_tests { use std::collections::HashMap; + use crate::model::repository::TaggedItem; use crate::tags::repository::get_tags_on_files; - use crate::{model::repository::Tag, repository::open_connection, test::*}; + use crate::{repository::open_connection, test::*}; #[test] fn returns_proper_mapping_for_file_tags() { @@ -339,8 +356,12 @@ mod get_tags_on_files_tests { 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()}]) + (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(); diff --git a/src/tags/tests/service.rs b/src/tags/tests/service.rs index c45a4d2..c5f6653 100644 --- a/src/tags/tests/service.rs +++ b/src/tags/tests/service.rs @@ -89,7 +89,7 @@ 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::model::response::TaggedItemApi; use crate::tags::service::{create_tag, get_tags_on_file, update_file_tags}; use crate::test::{cleanup, init_db_folder, now}; @@ -110,25 +110,29 @@ mod update_file_tag_test { update_file_tags( 1, vec![ - TagApi { - id: Some(1), + TaggedItemApi { + tag_id: Some(1), title: "test".to_string(), + implicit_from: None, }, - TagApi { - id: None, + TaggedItemApi { + tag_id: None, title: "new tag".to_string(), + implicit_from: None, }, ], ) .unwrap(); let expected = vec![ - TagApi { - id: Some(1), + TaggedItemApi { + tag_id: Some(1), title: "test".to_string(), + implicit_from: None, }, - TagApi { - id: Some(2), + TaggedItemApi { + tag_id: Some(2), title: "new tag".to_string(), + implicit_from: None, }, ]; let actual = get_tags_on_file(1).unwrap(); @@ -150,9 +154,10 @@ mod update_file_tag_test { .save_to_db(); update_file_tags( 1, - vec![TagApi { - id: None, + vec![TaggedItemApi { + tag_id: None, title: "test".to_string(), + implicit_from: None, }], ) .unwrap(); @@ -187,13 +192,15 @@ mod update_file_tag_test { update_file_tags( 1, vec![ - TagApi { - id: Some(1), + TaggedItemApi { + tag_id: Some(1), title: "test".to_string(), + implicit_from: None, }, - TagApi { - id: Some(1), + TaggedItemApi { + tag_id: Some(1), title: "test".to_string(), + implicit_from: None, }, ], ) @@ -201,7 +208,7 @@ mod update_file_tag_test { let actual = get_tags_on_file(1).unwrap(); assert_eq!(actual.len(), 1); - assert_eq!(actual[0].id, Some(1)); + assert_eq!(actual[0].tag_id, Some(1)); assert_eq!(actual[0].title, "test"); cleanup(); } @@ -223,13 +230,15 @@ mod update_file_tag_test { update_file_tags( 1, vec![ - TagApi { - id: None, + TaggedItemApi { + tag_id: None, title: "test".to_string(), + implicit_from: None, }, - TagApi { - id: None, + TaggedItemApi { + tag_id: None, title: "test".to_string(), + implicit_from: None, }, ], ) @@ -237,7 +246,7 @@ mod update_file_tag_test { let actual = get_tags_on_file(1).unwrap(); assert_eq!(actual.len(), 1); - assert_eq!(actual[0].id, Some(1)); + assert_eq!(actual[0].tag_id, Some(1)); assert_eq!(actual[0].title, "test"); cleanup(); } @@ -258,9 +267,10 @@ mod update_file_tag_test { // Mix of new tag by name and existing tag by id (same tag) update_file_tags( 1, - vec![TagApi { - id: None, + vec![TaggedItemApi { + tag_id: None, title: "test".to_string(), + implicit_from: None, }], ) .unwrap(); @@ -269,13 +279,15 @@ mod update_file_tag_test { update_file_tags( 1, vec![ - TagApi { - id: Some(1), + TaggedItemApi { + tag_id: Some(1), title: "test".to_string(), + implicit_from: None, }, - TagApi { - id: None, + TaggedItemApi { + tag_id: None, title: "test".to_string(), + implicit_from: None, }, ], ) @@ -283,7 +295,7 @@ mod update_file_tag_test { let actual = get_tags_on_file(1).unwrap(); assert_eq!(actual.len(), 1); - assert_eq!(actual[0].id, Some(1)); + assert_eq!(actual[0].tag_id, Some(1)); assert_eq!(actual[0].title, "test"); cleanup(); } @@ -292,7 +304,7 @@ mod update_file_tag_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::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}; @@ -315,25 +327,29 @@ mod update_folder_tag_test { update_folder_tags( 1, vec![ - TagApi { - id: Some(1), + TaggedItemApi { + tag_id: Some(1), title: "test".to_string(), + implicit_from: None, }, - TagApi { - id: None, + TaggedItemApi { + tag_id: None, title: "new tag".to_string(), + implicit_from: None, }, ], ) .unwrap(); let expected = vec![ - TagApi { - id: Some(1), + TaggedItemApi { + tag_id: Some(1), title: "test".to_string(), + implicit_from: None, }, - TagApi { - id: Some(2), + TaggedItemApi { + tag_id: Some(2), title: "new tag".to_string(), + implicit_from: None, }, ]; let actual = get_tags_on_folder(1).unwrap(); @@ -357,9 +373,10 @@ mod update_folder_tag_test { con.close().unwrap(); update_folder_tags( 1, - vec![TagApi { - id: None, + vec![TaggedItemApi { + tag_id: None, title: "test".to_string(), + implicit_from: None, }], ) .unwrap(); @@ -396,13 +413,15 @@ mod update_folder_tag_test { update_folder_tags( 1, vec![ - TagApi { - id: Some(1), + TaggedItemApi { + tag_id: Some(1), title: "test".to_string(), + implicit_from: None, }, - TagApi { - id: Some(1), + TaggedItemApi { + tag_id: Some(1), title: "test".to_string(), + implicit_from: None, }, ], ) @@ -410,7 +429,7 @@ mod update_folder_tag_test { let actual = get_tags_on_folder(1).unwrap(); assert_eq!(actual.len(), 1); - assert_eq!(actual[0].id, Some(1)); + assert_eq!(actual[0].tag_id, Some(1)); assert_eq!(actual[0].title, "test"); cleanup(); } @@ -434,13 +453,15 @@ mod update_folder_tag_test { update_folder_tags( 1, vec![ - TagApi { - id: None, + TaggedItemApi { + tag_id: None, title: "test".to_string(), + implicit_from: None, }, - TagApi { - id: None, + TaggedItemApi { + tag_id: None, title: "test".to_string(), + implicit_from: None, }, ], ) @@ -448,7 +469,7 @@ mod update_folder_tag_test { let actual = get_tags_on_folder(1).unwrap(); assert_eq!(actual.len(), 1); - assert_eq!(actual[0].id, Some(1)); + assert_eq!(actual[0].tag_id, Some(1)); assert_eq!(actual[0].title, "test"); cleanup(); } @@ -471,9 +492,10 @@ mod update_folder_tag_test { // Mix of new tag by name and existing tag by id (same tag) update_folder_tags( 1, - vec![TagApi { - id: None, + vec![TaggedItemApi { + tag_id: None, title: "test".to_string(), + implicit_from: None, }], ) .unwrap(); @@ -482,13 +504,15 @@ mod update_folder_tag_test { update_folder_tags( 1, vec![ - TagApi { - id: Some(1), + TaggedItemApi { + tag_id: Some(1), title: "test".to_string(), + implicit_from: None, }, - TagApi { - id: None, + TaggedItemApi { + tag_id: None, title: "test".to_string(), + implicit_from: None, }, ], ) @@ -496,7 +520,7 @@ mod update_folder_tag_test { let actual = get_tags_on_folder(1).unwrap(); assert_eq!(actual.len(), 1); - assert_eq!(actual[0].id, Some(1)); + assert_eq!(actual[0].tag_id, Some(1)); assert_eq!(actual[0].title, "test"); cleanup(); } diff --git a/src/test/mod.rs b/src/test/mod.rs index e157781..23f4e87 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -129,8 +129,17 @@ mod tests { connection.close().unwrap(); } - pub fn inherit_tag_folder(name: &str, folder_id: u32, inherited_from: u32) { + 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})" + ); + // 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) { @@ -245,7 +254,7 @@ mod tests { for tag in &mut self.tags { let Tag { id, title: _ } = tag_repository::create_tag(&tag.title, &con).unwrap(); tag_repository::add_explicit_tag_to_file(file_id, id, &con).unwrap(); - tag.id = Some(id); + 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(); From 17c12027cdde9b7e5d7b3506e2cb731e0c83dc18 Mon Sep 17 00:00:00 2001 From: ploiu Date: Wed, 19 Nov 2025 02:18:00 +0000 Subject: [PATCH 18/61] stub function --- src/tags/service.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/tags/service.rs b/src/tags/service.rs index eecce49..08d150d 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -199,8 +199,7 @@ pub fn update_file_tags(file_id: u32, tags: Vec) -> Result<(), Ta } 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 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); @@ -395,3 +394,11 @@ pub fn get_tags_on_folder(folder_id: u32) -> Result, TagRelat con.close().unwrap(); Ok(db_tags.into_iter().map(TaggedItemApi::from).collect()) } + +/// gets all explicit tags on the folder with the passed id, and implies it on all descendant files and folders. +/// +/// In order for a tag to be implied, the target file/folder must not already have it (explicit or implicit). +/// +/// ## Parameters +/// - `folder_id`: the id of the folder to implicate children of +pub fn implicate_children(folder_id: u32) -> Result<(), TagRelationError> {} From 4a4b7f71d3b8e6d7cd6456a9b0731b65d67ea68c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 00:22:00 +0000 Subject: [PATCH 19/61] Initial plan From d09c95cd2e2295df397ca3194b8b0f8d53fd8416 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 00:37:51 +0000 Subject: [PATCH 20/61] Implement tag propagation for folder descendants - Created SQL queries for descendant retrieval and tag management - Added repository functions for tag manipulation - Implemented pass_tags_to_children service function - Integrated with update_folder_tags to auto-propagate changes - Added comprehensive tests for all scenarios Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- .../queries/tags/add_implicit_tag_to_file.sql | 3 + .../tags/add_implicit_tag_to_folder.sql | 3 + .../queries/tags/get_descendant_files.sql | 13 + .../queries/tags/get_descendant_folders.sql | 13 + .../queries/tags/get_files_without_tag.sql | 11 + .../queries/tags/get_folders_without_tag.sql | 11 + .../tags/remove_implicit_tags_from_files.sql | 5 + .../remove_implicit_tags_from_folders.sql | 5 + .../tags/upsert_implicit_tag_to_file.sql | 9 + .../tags/upsert_implicit_tag_to_folder.sql | 9 + src/service/folder_service.rs | 100 ++++ src/tags/repository.rs | 172 +++++++ src/tags/service.rs | 463 +++++++++++++++++- src/tags/tests/service.rs | 205 ++++++++ 14 files changed, 1017 insertions(+), 5 deletions(-) create mode 100644 src/assets/queries/tags/add_implicit_tag_to_file.sql create mode 100644 src/assets/queries/tags/add_implicit_tag_to_folder.sql create mode 100644 src/assets/queries/tags/get_descendant_files.sql create mode 100644 src/assets/queries/tags/get_descendant_folders.sql create mode 100644 src/assets/queries/tags/get_files_without_tag.sql create mode 100644 src/assets/queries/tags/get_folders_without_tag.sql create mode 100644 src/assets/queries/tags/remove_implicit_tags_from_files.sql create mode 100644 src/assets/queries/tags/remove_implicit_tags_from_folders.sql create mode 100644 src/assets/queries/tags/upsert_implicit_tag_to_file.sql create mode 100644 src/assets/queries/tags/upsert_implicit_tag_to_folder.sql 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/get_descendant_files.sql b/src/assets/queries/tags/get_descendant_files.sql new file mode 100644 index 0000000..491aa2e --- /dev/null +++ b/src/assets/queries/tags/get_descendant_files.sql @@ -0,0 +1,13 @@ +-- Recursively get all descendant file IDs for a given folder +with recursive descendants(folderId) as ( + -- base case: the folder itself + select ?1 as folderId + union all + -- recursive case: all descendant folders + select f.id + from Folders f + join descendants d on f.parentId = d.folderId +) +select distinct ff.fileId +from Folder_Files ff +join descendants d on ff.folderId = d.folderId diff --git a/src/assets/queries/tags/get_descendant_folders.sql b/src/assets/queries/tags/get_descendant_folders.sql new file mode 100644 index 0000000..7d3c80a --- /dev/null +++ b/src/assets/queries/tags/get_descendant_folders.sql @@ -0,0 +1,13 @@ +-- Recursively get all descendant folder IDs for a given folder +with recursive descendants(folderId) as ( + -- base case: direct children of the folder + select id as folderId + from Folders + where parentId = ?1 + union all + -- recursive case: children of children + select f.id + from Folders f + join descendants d on f.parentId = d.folderId +) +select folderId from descendants diff --git a/src/assets/queries/tags/get_files_without_tag.sql b/src/assets/queries/tags/get_files_without_tag.sql new file mode 100644 index 0000000..0147e46 --- /dev/null +++ b/src/assets/queries/tags/get_files_without_tag.sql @@ -0,0 +1,11 @@ +-- Get files from a list that don't have a specific tag (explicit or implicit) +-- Input: file IDs as a formatted IN clause string (?1), and tag ID (?2) +select id +from FileRecords +where id in ({}) + and id not in ( + select fileId + from TaggedItems + where tagId = ? + and fileId is not null + ) diff --git a/src/assets/queries/tags/get_folders_without_tag.sql b/src/assets/queries/tags/get_folders_without_tag.sql new file mode 100644 index 0000000..5b9077f --- /dev/null +++ b/src/assets/queries/tags/get_folders_without_tag.sql @@ -0,0 +1,11 @@ +-- Get folders from a list that don't have a specific tag (explicit or implicit) +-- Input: folder IDs as a formatted IN clause string (?1), and tag ID (?2) +select id +from Folders +where id in ({}) + and id not in ( + select folderId + from TaggedItems + where tagId = ? + and folderId is not null + ) diff --git a/src/assets/queries/tags/remove_implicit_tags_from_files.sql b/src/assets/queries/tags/remove_implicit_tags_from_files.sql new file mode 100644 index 0000000..9251ed0 --- /dev/null +++ b/src/assets/queries/tags/remove_implicit_tags_from_files.sql @@ -0,0 +1,5 @@ +-- Remove implicit tags from files where the tag is inherited from a specific folder +delete from TaggedItems +where fileId in (?1) + and tagId = ?1 + and implicitFromId = ?2 diff --git a/src/assets/queries/tags/remove_implicit_tags_from_folders.sql b/src/assets/queries/tags/remove_implicit_tags_from_folders.sql new file mode 100644 index 0000000..f623092 --- /dev/null +++ b/src/assets/queries/tags/remove_implicit_tags_from_folders.sql @@ -0,0 +1,5 @@ +-- Remove implicit tags from folders where the tag is inherited from a specific folder +delete from TaggedItems +where folderId in (?1) + and tagId = ?1 + and implicitFromId = ?2 diff --git a/src/assets/queries/tags/upsert_implicit_tag_to_file.sql b/src/assets/queries/tags/upsert_implicit_tag_to_file.sql new file mode 100644 index 0000000..a880727 --- /dev/null +++ b/src/assets/queries/tags/upsert_implicit_tag_to_file.sql @@ -0,0 +1,9 @@ +-- Update or insert implicit tag for a file +-- First delete any existing implicit tag for this tag/file combination, then insert the new one +delete from TaggedItems +where fileId = ?1 + and tagId = ?2 + and implicitFromId is not null; + +insert into TaggedItems(tagId, fileId, implicitFromId) +values (?2, ?1, ?3) diff --git a/src/assets/queries/tags/upsert_implicit_tag_to_folder.sql b/src/assets/queries/tags/upsert_implicit_tag_to_folder.sql new file mode 100644 index 0000000..27027c5 --- /dev/null +++ b/src/assets/queries/tags/upsert_implicit_tag_to_folder.sql @@ -0,0 +1,9 @@ +-- Update or insert implicit tag for a folder +-- First delete any existing implicit tag for this tag/folder combination, then insert the new one +delete from TaggedItems +where folderId = ?1 + and tagId = ?2 + and implicitFromId is not null; + +insert into TaggedItems(tagId, folderId, implicitFromId) +values (?2, ?1, ?3) diff --git a/src/service/folder_service.rs b/src/service/folder_service.rs index 1d0cf3c..cdefc1b 100644 --- a/src/service/folder_service.rs +++ b/src/service/folder_service.rs @@ -735,6 +735,106 @@ mod update_folder_tests { assert_eq!(expected, get_folder(Some(1)).unwrap()); cleanup(); } + + #[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"); + + 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(); + assert_eq!(child.tags.len(), 1); + assert_eq!(child.tags[0].tag_id, Some(1)); + assert_eq!(child.tags[0].title, "tag1"); + assert_eq!(child.tags[0].implicit_from, Some(1)); + cleanup(); + } + + #[test] + fn update_folder_implies_tags_to_descendant_files() { + init_db_folder(); + create_folder_db_entry("parent", None); + create_folder_disk("parent"); + + use crate::test::create_file_db_entry; + 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 + use crate::tags::service::get_tags_on_file; + let file_tags = get_tags_on_file(1).unwrap(); + assert_eq!(file_tags.len(), 1); + assert_eq!(file_tags[0].tag_id, Some(1)); + assert_eq!(file_tags[0].title, "tag1"); + assert_eq!(file_tags[0].implicit_from, Some(1)); + 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(); + } } #[cfg(test)] diff --git a/src/tags/repository.rs b/src/tags/repository.rs index 8900f9b..9ba504d 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -172,6 +172,178 @@ fn tag_mapper(row: &rusqlite::Row) -> Result { Ok(repository::Tag { id, title }) } +/// Gets all descendant folder IDs recursively for a given folder +pub fn get_descendant_folders( + folder_id: u32, + con: &Connection, +) -> Result, rusqlite::Error> { + let mut pst = con.prepare(include_str!( + "../assets/queries/tags/get_descendant_folders.sql" + ))?; + let rows = pst.query_map(rusqlite::params![folder_id], |row| row.get(0))?; + rows.collect::, rusqlite::Error>>() +} + +/// Gets all descendant file IDs recursively for a given folder +pub fn get_descendant_files( + folder_id: u32, + con: &Connection, +) -> Result, rusqlite::Error> { + let mut pst = con.prepare(include_str!( + "../assets/queries/tags/get_descendant_files.sql" + ))?; + let rows = pst.query_map(rusqlite::params![folder_id], |row| row.get(0))?; + rows.collect::, rusqlite::Error>>() +} + +/// Gets folder IDs from the provided list that don't have a specific tag (explicit or implicit) +pub fn get_folders_without_tag( + folder_ids: &[u32], + tag_id: u32, + con: &Connection, +) -> Result, rusqlite::Error> { + if folder_ids.is_empty() { + return Ok(vec![]); + } + let in_clause: String = folder_ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + let query = include_str!("../assets/queries/tags/get_folders_without_tag.sql") + .replace("{}", &in_clause); + let mut pst = con.prepare(&query)?; + let rows = pst.query_map(rusqlite::params![tag_id], |row| row.get(0))?; + rows.collect::, rusqlite::Error>>() +} + +/// Gets file IDs from the provided list that don't have a specific tag (explicit or implicit) +pub fn get_files_without_tag( + file_ids: &[u32], + tag_id: u32, + con: &Connection, +) -> Result, rusqlite::Error> { + if file_ids.is_empty() { + return Ok(vec![]); + } + let in_clause: String = file_ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + let query = include_str!("../assets/queries/tags/get_files_without_tag.sql") + .replace("{}", &in_clause); + let mut pst = con.prepare(&query)?; + let rows = pst.query_map(rusqlite::params![tag_id], |row| row.get(0))?; + rows.collect::, rusqlite::Error>>() +} + +/// Adds an implicit tag to a folder (won't add if already exists) +pub fn add_implicit_tag_to_folder( + tag_id: u32, + folder_id: 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" + ))?; + pst.execute(rusqlite::params![tag_id, folder_id, implicit_from_id])?; + Ok(()) +} + +/// Updates or inserts an implicit tag on a folder, replacing any existing implicit tag from a different ancestor +pub fn upsert_implicit_tag_to_folder( + tag_id: u32, + folder_id: u32, + implicit_from_id: u32, + con: &Connection, +) -> Result<(), rusqlite::Error> { + // First delete any existing implicit tag + let delete_sql = "delete from TaggedItems where folderId = ?1 and tagId = ?2 and implicitFromId is not null"; + con.execute(delete_sql, rusqlite::params![folder_id, tag_id])?; + + // Then insert the new one + let insert_sql = "insert into TaggedItems(tagId, folderId, implicitFromId) values (?1, ?2, ?3)"; + con.execute(insert_sql, rusqlite::params![tag_id, folder_id, implicit_from_id])?; + Ok(()) +} + +/// Adds an implicit tag to a file (won't add if already exists) +pub fn add_implicit_tag_to_file( + tag_id: u32, + file_id: u32, + implicit_from_id: u32, + con: &Connection, +) -> Result<(), rusqlite::Error> { + let mut pst = con.prepare(include_str!( + "../assets/queries/tags/add_implicit_tag_to_file.sql" + ))?; + pst.execute(rusqlite::params![tag_id, file_id, implicit_from_id])?; + Ok(()) +} + +/// Updates or inserts an implicit tag on a file, replacing any existing implicit tag from a different ancestor +pub fn upsert_implicit_tag_to_file( + tag_id: u32, + file_id: u32, + implicit_from_id: u32, + con: &Connection, +) -> Result<(), rusqlite::Error> { + // First delete any existing implicit tag + let delete_sql = "delete from TaggedItems where fileId = ?1 and tagId = ?2 and implicitFromId is not null"; + con.execute(delete_sql, rusqlite::params![file_id, tag_id])?; + + // Then insert the new one + let insert_sql = "insert into TaggedItems(tagId, fileId, implicitFromId) values (?1, ?2, ?3)"; + con.execute(insert_sql, rusqlite::params![tag_id, file_id, implicit_from_id])?; + Ok(()) +} + +/// Removes implicit tags from folders where inherited from a specific folder +pub fn remove_implicit_tags_from_folders( + folder_ids: &[u32], + tag_id: u32, + implicit_from_id: u32, + con: &Connection, +) -> Result<(), rusqlite::Error> { + if folder_ids.is_empty() { + return Ok(()); + } + let in_clause: String = folder_ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + let query = include_str!("../assets/queries/tags/remove_implicit_tags_from_folders.sql") + .replace("(?1)", &format!("({})", in_clause)); + let mut pst = con.prepare(&query)?; + pst.execute(rusqlite::params![tag_id, implicit_from_id])?; + Ok(()) +} + +/// Removes implicit tags from files where inherited from a specific folder +pub fn remove_implicit_tags_from_files( + file_ids: &[u32], + tag_id: u32, + implicit_from_id: u32, + con: &Connection, +) -> Result<(), rusqlite::Error> { + if file_ids.is_empty() { + return Ok(()); + } + let in_clause: String = file_ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + let query = include_str!("../assets/queries/tags/remove_implicit_tags_from_files.sql") + .replace("(?1)", &format!("({})", in_clause)); + let mut pst = con.prepare(&query)?; + pst.execute(rusqlite::params![tag_id, implicit_from_id])?; + Ok(()) +} + /// 1. id /// 2. fileId /// 3. folderId diff --git a/src/tags/service.rs b/src/tags/service.rs index 08d150d..a1394d5 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -2,6 +2,7 @@ use std::backtrace::Backtrace; use std::collections::HashSet; use itertools::Itertools; +use rusqlite::Connection; use crate::model::error::file_errors::GetFileError; use crate::model::error::tag_errors::{ @@ -9,7 +10,7 @@ use crate::model::error::tag_errors::{ }; use crate::model::repository::{self}; use crate::model::response::{TagApi, TaggedItemApi}; -use crate::repository::open_connection; +use crate::repository::{folder_repository, open_connection}; use crate::service::{file_service, folder_service}; use crate::tags::repository as tag_repository; @@ -339,6 +340,10 @@ pub fn update_folder_tags( } con.close().unwrap(); + + // Propagate tag changes to all descendants + pass_tags_to_children(folder_id)?; + Ok(()) } @@ -395,10 +400,458 @@ pub fn get_tags_on_folder(folder_id: u32) -> Result, TagRelat Ok(db_tags.into_iter().map(TaggedItemApi::from).collect()) } -/// gets all explicit tags on the folder with the passed id, and implies it on all descendant files and folders. +/// Propagates tag changes from a folder to all its descendant files and folders. /// -/// In order for a tag to be implied, the target file/folder must not already have it (explicit or implicit). +/// 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 to implicate children of -pub fn implicate_children(folder_id: u32) -> Result<(), TagRelationError> {} +/// - `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_children(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 explicit tags on this folder + let folder_tags = match tag_repository::get_tags_on_folder(folder_id, &con) { + Ok(tags) => tags + .into_iter() + .filter(|t| t.implicit_from_id.is_none()) + .collect::>(), + Err(e) => { + log::error!( + "Failed to retrieve tags on folder {folder_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(TagRelationError::DbError); + } + }; + + // Get all descendant folders and files + let descendant_folders = match tag_repository::get_descendant_folders(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); + } + }; + + let descendant_files = match tag_repository::get_descendant_files(folder_id, &con) { + Ok(files) => files, + 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); + } + }; + + // Get all tag IDs that this folder has explicitly + let folder_tag_ids: HashSet = folder_tags.iter().map(|t| t.tag_id).collect(); + + // Remove all implicit tags from descendants that the folder doesn't have + // This handles the case where a tag was removed from the folder + if let Err(e) = remove_orphaned_implications( + folder_id, + &descendant_folders, + &descendant_files, + &folder_tag_ids, + &con, + ) { + con.close().unwrap(); + return Err(e); + } + + // Add implications for all tags the folder has + for tag in folder_tags { + if let Err(e) = + add_tag_to_descendants(tag.tag_id, folder_id, &descendant_folders, &descendant_files, &con) + { + con.close().unwrap(); + return Err(e); + } + } + + con.close().unwrap(); + Ok(()) +} + +/// Removes implicit tags from descendants that are inherited from this folder but the folder no longer has +fn remove_orphaned_implications( + folder_id: u32, + descendant_folders: &[u32], + descendant_files: &[u32], + current_tag_ids: &HashSet, + con: &Connection, +) -> Result<(), TagRelationError> { + // Get all unique tag IDs that are currently implied from this folder to any descendant + let mut implied_tags: HashSet = HashSet::new(); + + // Check folders + for folder in descendant_folders { + let tags = match tag_repository::get_tags_on_folder(*folder, con) { + Ok(t) => t, + Err(e) => { + log::error!( + "Failed to retrieve tags on folder {folder}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + }; + for tag in tags { + if tag.implicit_from_id == Some(folder_id) { + implied_tags.insert(tag.tag_id); + } + } + } + + // Check files + for file in descendant_files { + let tags = match tag_repository::get_tags_on_file(*file, con) { + Ok(t) => t, + Err(e) => { + log::error!( + "Failed to retrieve tags on file {file}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + }; + for tag in tags { + if tag.implicit_from_id == Some(folder_id) { + implied_tags.insert(tag.tag_id); + } + } + } + + // Remove implications for tags that are no longer on the folder + for tag_id in implied_tags { + if !current_tag_ids.contains(&tag_id) { + // Remove from folders + if let Err(e) = tag_repository::remove_implicit_tags_from_folders( + descendant_folders, + tag_id, + folder_id, + con, + ) { + log::error!( + "Failed to remove implicit tag {tag_id} from descendant folders! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + + // Remove from files + if let Err(e) = tag_repository::remove_implicit_tags_from_files( + descendant_files, + tag_id, + folder_id, + con, + ) { + log::error!( + "Failed to remove implicit tag {tag_id} from descendant files! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + + // After removing the tag, check if any descendant needs to re-inherit from a higher ancestor + if let Err(e) = re_inherit_from_ancestors( + folder_id, + tag_id, + descendant_folders, + descendant_files, + con, + ) { + return Err(e); + } + } + } + + Ok(()) +} + +/// After removing an implicit tag, check if descendants need to inherit it from a higher ancestor +fn re_inherit_from_ancestors( + _removed_from_folder_id: u32, + tag_id: u32, + descendant_folders: &[u32], + descendant_files: &[u32], + con: &Connection, +) -> Result<(), TagRelationError> { + // For each descendant folder, walk up the parent chain to find if any ancestor has this tag + for folder_id in descendant_folders { + if let Some(new_implicit_from) = find_ancestor_with_tag(*folder_id, tag_id, con)? { + // Only re-inherit if the folder doesn't have the tag explicitly + let tags = match tag_repository::get_tags_on_folder(*folder_id, con) { + Ok(t) => t, + Err(e) => { + log::error!( + "Failed to get tags for folder {folder_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + }; + let has_explicit = tags + .iter() + .any(|t| t.tag_id == tag_id && t.implicit_from_id.is_none()); + if !has_explicit { + if let Err(e) = + tag_repository::add_implicit_tag_to_folder(tag_id, *folder_id, new_implicit_from, con) + { + log::error!( + "Failed to re-inherit tag {tag_id} to folder {folder_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + } + } + } + + // For each descendant file, walk up the parent chain to find if any ancestor has this tag + for file_id in descendant_files { + if let Some(new_implicit_from) = find_ancestor_with_tag_for_file(*file_id, tag_id, con)? { + // Only re-inherit if the file doesn't have the tag explicitly + let tags = match tag_repository::get_tags_on_file(*file_id, con) { + Ok(t) => t, + Err(e) => { + log::error!( + "Failed to get tags for file {file_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + }; + let has_explicit = tags + .iter() + .any(|t| t.tag_id == tag_id && t.implicit_from_id.is_none()); + if !has_explicit { + if let Err(e) = + tag_repository::add_implicit_tag_to_file(tag_id, *file_id, new_implicit_from, con) + { + log::error!( + "Failed to re-inherit tag {tag_id} to file {file_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + } + } + } + + Ok(()) +} + +/// Finds the nearest ancestor folder that has the specified tag explicitly +fn find_ancestor_with_tag( + folder_id: u32, + tag_id: u32, + con: &Connection, +) -> Result, TagRelationError> { + // Get the folder to find its parent + let folder = match folder_repository::get_by_id(Some(folder_id), con) { + Ok(f) => f, + Err(e) => { + log::error!( + "Failed to get folder {folder_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + }; + + let mut current_parent = folder.parent_id; + + // Walk up the parent chain + while let Some(parent_id) = current_parent { + // Check if this parent has the tag explicitly + let tags = match tag_repository::get_tags_on_folder(parent_id, con) { + Ok(t) => t, + Err(e) => { + log::error!( + "Failed to get tags for folder {parent_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + }; + + if tags + .iter() + .any(|t| t.tag_id == tag_id && t.implicit_from_id.is_none()) + { + return Ok(Some(parent_id)); + } + + // Move to the next parent + let parent = match folder_repository::get_by_id(Some(parent_id), con) { + Ok(f) => f, + Err(e) => { + log::error!( + "Failed to get folder {parent_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + }; + current_parent = parent.parent_id; + } + + Ok(None) +} + +/// Finds the nearest ancestor folder of a file that has the specified tag explicitly +fn find_ancestor_with_tag_for_file( + file_id: u32, + tag_id: u32, + con: &Connection, +) -> Result, TagRelationError> { + // Get the file's parent folder + let file_record = match file_service::get_file_metadata(file_id) { + Ok(f) => f, + Err(e) => { + log::error!( + "Failed to get file {file_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + }; + + let mut current_parent = file_record.folder_id; + + // Walk up the folder parent chain + while let Some(parent_id) = current_parent { + // Check if this folder has the tag explicitly + let tags = match tag_repository::get_tags_on_folder(parent_id, con) { + Ok(t) => t, + Err(e) => { + log::error!( + "Failed to get tags for folder {parent_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + }; + + if tags + .iter() + .any(|t| t.tag_id == tag_id && t.implicit_from_id.is_none()) + { + return Ok(Some(parent_id)); + } + + // Move to the next parent + let parent = match folder_repository::get_by_id(Some(parent_id), con) { + Ok(f) => f, + Err(e) => { + log::error!( + "Failed to get folder {parent_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + }; + current_parent = parent.parent_id; + } + + Ok(None) +} + +/// Adds a tag to all descendants that don't already have it explicitly +fn add_tag_to_descendants( + tag_id: u32, + folder_id: u32, + descendant_folders: &[u32], + descendant_files: &[u32], + con: &Connection, +) -> Result<(), TagRelationError> { + // For each descendant folder, check if it should have this implicit tag + for descendant_folder_id in descendant_folders { + let tags = match tag_repository::get_tags_on_folder(*descendant_folder_id, con) { + Ok(t) => t, + Err(e) => { + log::error!( + "Failed to get tags for folder {descendant_folder_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + }; + + // Check if folder has this tag explicitly - if so, don't override + let has_explicit = tags + .iter() + .any(|t| t.tag_id == tag_id && t.implicit_from_id.is_none()); + + if has_explicit { + continue; + } + + // Add or update the implicit tag + if let Err(e) = tag_repository::upsert_implicit_tag_to_folder(tag_id, *descendant_folder_id, folder_id, con) { + log::error!( + "Failed to upsert implicit tag {tag_id} to folder {descendant_folder_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + } + + // For each descendant file, check if it should have this implicit tag + for descendant_file_id in descendant_files { + let tags = match tag_repository::get_tags_on_file(*descendant_file_id, con) { + Ok(t) => t, + Err(e) => { + log::error!( + "Failed to get tags for file {descendant_file_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + }; + + // Check if file has this tag explicitly - if so, don't override + let has_explicit = tags + .iter() + .any(|t| t.tag_id == tag_id && t.implicit_from_id.is_none()); + + if has_explicit { + continue; + } + + // Add or update the implicit tag + if let Err(e) = tag_repository::upsert_implicit_tag_to_file(tag_id, *descendant_file_id, folder_id, con) { + log::error!( + "Failed to upsert implicit tag {tag_id} to file {descendant_file_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + } + + Ok(()) +} diff --git a/src/tags/tests/service.rs b/src/tags/tests/service.rs index c5f6653..0bae8dc 100644 --- a/src/tags/tests/service.rs +++ b/src/tags/tests/service.rs @@ -553,3 +553,208 @@ mod get_tags_on_folder_tests { cleanup(); } } + +mod pass_tags_to_children_tests { + use crate::model::response::TaggedItemApi; + use crate::tags::service::{get_tags_on_file, get_tags_on_folder, pass_tags_to_children}; + use crate::test::{ + cleanup, create_file_db_entry, create_folder_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_children(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_children(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 + + // Create tag and add explicitly to both + use crate::test::create_tag_db_entry; + use crate::repository::open_connection; + use crate::tags::repository as tag_repository; + 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_children(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 + + // Create tag and add explicitly to both + use crate::test::create_tag_db_entry; + use crate::repository::open_connection; + use crate::tags::repository as tag_repository; + 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_children(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_children(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)); + + // Remove the tag explicitly from parent + use crate::repository::open_connection; + use crate::tags::repository as tag_repository; + let con = open_connection(); + tag_repository::remove_tag_from_folder(1, 1, &con).unwrap(); + con.close().unwrap(); + + // Propagate the change + pass_tags_to_children(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 + use crate::test::create_tag_db_entry; + use crate::repository::open_connection; + use crate::tags::repository as tag_repository; + 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(1).unwrap(); + pass_tags_to_children(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)); + + // Remove tag from parent + use crate::tags::service::update_folder_tags; + 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(); + } +} From 917fd259f015d51bf303b96bfa6a1bc6f178df5c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 00:39:40 +0000 Subject: [PATCH 21/61] Remove unused helper functions and SQL files Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- .../queries/tags/get_files_without_tag.sql | 11 ----- .../queries/tags/get_folders_without_tag.sql | 11 ----- .../tags/upsert_implicit_tag_to_file.sql | 9 ---- .../tags/upsert_implicit_tag_to_folder.sql | 9 ---- src/tags/repository.rs | 42 ------------------- 5 files changed, 82 deletions(-) delete mode 100644 src/assets/queries/tags/get_files_without_tag.sql delete mode 100644 src/assets/queries/tags/get_folders_without_tag.sql delete mode 100644 src/assets/queries/tags/upsert_implicit_tag_to_file.sql delete mode 100644 src/assets/queries/tags/upsert_implicit_tag_to_folder.sql diff --git a/src/assets/queries/tags/get_files_without_tag.sql b/src/assets/queries/tags/get_files_without_tag.sql deleted file mode 100644 index 0147e46..0000000 --- a/src/assets/queries/tags/get_files_without_tag.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Get files from a list that don't have a specific tag (explicit or implicit) --- Input: file IDs as a formatted IN clause string (?1), and tag ID (?2) -select id -from FileRecords -where id in ({}) - and id not in ( - select fileId - from TaggedItems - where tagId = ? - and fileId is not null - ) diff --git a/src/assets/queries/tags/get_folders_without_tag.sql b/src/assets/queries/tags/get_folders_without_tag.sql deleted file mode 100644 index 5b9077f..0000000 --- a/src/assets/queries/tags/get_folders_without_tag.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Get folders from a list that don't have a specific tag (explicit or implicit) --- Input: folder IDs as a formatted IN clause string (?1), and tag ID (?2) -select id -from Folders -where id in ({}) - and id not in ( - select folderId - from TaggedItems - where tagId = ? - and folderId is not null - ) diff --git a/src/assets/queries/tags/upsert_implicit_tag_to_file.sql b/src/assets/queries/tags/upsert_implicit_tag_to_file.sql deleted file mode 100644 index a880727..0000000 --- a/src/assets/queries/tags/upsert_implicit_tag_to_file.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Update or insert implicit tag for a file --- First delete any existing implicit tag for this tag/file combination, then insert the new one -delete from TaggedItems -where fileId = ?1 - and tagId = ?2 - and implicitFromId is not null; - -insert into TaggedItems(tagId, fileId, implicitFromId) -values (?2, ?1, ?3) diff --git a/src/assets/queries/tags/upsert_implicit_tag_to_folder.sql b/src/assets/queries/tags/upsert_implicit_tag_to_folder.sql deleted file mode 100644 index 27027c5..0000000 --- a/src/assets/queries/tags/upsert_implicit_tag_to_folder.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Update or insert implicit tag for a folder --- First delete any existing implicit tag for this tag/folder combination, then insert the new one -delete from TaggedItems -where folderId = ?1 - and tagId = ?2 - and implicitFromId is not null; - -insert into TaggedItems(tagId, folderId, implicitFromId) -values (?2, ?1, ?3) diff --git a/src/tags/repository.rs b/src/tags/repository.rs index 9ba504d..9af8069 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -196,48 +196,6 @@ pub fn get_descendant_files( rows.collect::, rusqlite::Error>>() } -/// Gets folder IDs from the provided list that don't have a specific tag (explicit or implicit) -pub fn get_folders_without_tag( - folder_ids: &[u32], - tag_id: u32, - con: &Connection, -) -> Result, rusqlite::Error> { - if folder_ids.is_empty() { - return Ok(vec![]); - } - let in_clause: String = folder_ids - .iter() - .map(|id| id.to_string()) - .collect::>() - .join(","); - let query = include_str!("../assets/queries/tags/get_folders_without_tag.sql") - .replace("{}", &in_clause); - let mut pst = con.prepare(&query)?; - let rows = pst.query_map(rusqlite::params![tag_id], |row| row.get(0))?; - rows.collect::, rusqlite::Error>>() -} - -/// Gets file IDs from the provided list that don't have a specific tag (explicit or implicit) -pub fn get_files_without_tag( - file_ids: &[u32], - tag_id: u32, - con: &Connection, -) -> Result, rusqlite::Error> { - if file_ids.is_empty() { - return Ok(vec![]); - } - let in_clause: String = file_ids - .iter() - .map(|id| id.to_string()) - .collect::>() - .join(","); - let query = include_str!("../assets/queries/tags/get_files_without_tag.sql") - .replace("{}", &in_clause); - let mut pst = con.prepare(&query)?; - let rows = pst.query_map(rusqlite::params![tag_id], |row| row.get(0))?; - rows.collect::, rusqlite::Error>>() -} - /// Adds an implicit tag to a folder (won't add if already exists) pub fn add_implicit_tag_to_folder( tag_id: u32, From ae2b783fd6c6190ca40c6455d32c63a2e9b7ddb3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 01:25:37 +0000 Subject: [PATCH 22/61] Address code review feedback: use existing folder_repository functions, move SQL to files, fix tests, add repository tests - Removed duplicate get_descendant_folders/files SQL and functions - Use folder_repository::get_all_child_folder_ids and get_child_files instead - Moved SQL from Rust code to dedicated SQL files - Fixed test assertions to use expected TaggedItemApi objects - Added comprehensive repository tests for implicit tag functions Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- .../tags/delete_implicit_tag_from_file.sql | 4 + .../tags/delete_implicit_tag_from_folder.sql | 4 + .../queries/tags/get_descendant_files.sql | 13 -- .../queries/tags/get_descendant_folders.sql | 13 -- src/service/folder_service.rs | 18 ++- src/tags/repository.rs | 48 +++---- src/tags/service.rs | 9 +- src/tags/tests/repository.rs | 132 ++++++++++++++++++ 8 files changed, 174 insertions(+), 67 deletions(-) create mode 100644 src/assets/queries/tags/delete_implicit_tag_from_file.sql create mode 100644 src/assets/queries/tags/delete_implicit_tag_from_folder.sql delete mode 100644 src/assets/queries/tags/get_descendant_files.sql delete mode 100644 src/assets/queries/tags/get_descendant_folders.sql diff --git a/src/assets/queries/tags/delete_implicit_tag_from_file.sql b/src/assets/queries/tags/delete_implicit_tag_from_file.sql new file mode 100644 index 0000000..b62baec --- /dev/null +++ b/src/assets/queries/tags/delete_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/delete_implicit_tag_from_folder.sql b/src/assets/queries/tags/delete_implicit_tag_from_folder.sql new file mode 100644 index 0000000..6e216b5 --- /dev/null +++ b/src/assets/queries/tags/delete_implicit_tag_from_folder.sql @@ -0,0 +1,4 @@ +delete from TaggedItems +where folderId = ?1 + and tagId = ?2 + and implicitFromId is not null diff --git a/src/assets/queries/tags/get_descendant_files.sql b/src/assets/queries/tags/get_descendant_files.sql deleted file mode 100644 index 491aa2e..0000000 --- a/src/assets/queries/tags/get_descendant_files.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Recursively get all descendant file IDs for a given folder -with recursive descendants(folderId) as ( - -- base case: the folder itself - select ?1 as folderId - union all - -- recursive case: all descendant folders - select f.id - from Folders f - join descendants d on f.parentId = d.folderId -) -select distinct ff.fileId -from Folder_Files ff -join descendants d on ff.folderId = d.folderId diff --git a/src/assets/queries/tags/get_descendant_folders.sql b/src/assets/queries/tags/get_descendant_folders.sql deleted file mode 100644 index 7d3c80a..0000000 --- a/src/assets/queries/tags/get_descendant_folders.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Recursively get all descendant folder IDs for a given folder -with recursive descendants(folderId) as ( - -- base case: direct children of the folder - select id as folderId - from Folders - where parentId = ?1 - union all - -- recursive case: children of children - select f.id - from Folders f - join descendants d on f.parentId = d.folderId -) -select folderId from descendants diff --git a/src/service/folder_service.rs b/src/service/folder_service.rs index cdefc1b..c18e01a 100644 --- a/src/service/folder_service.rs +++ b/src/service/folder_service.rs @@ -758,10 +758,13 @@ mod update_folder_tests { // 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].tag_id, Some(1)); - assert_eq!(child.tags[0].title, "tag1"); - assert_eq!(child.tags[0].implicit_from, Some(1)); + assert_eq!(child.tags[0], expected); cleanup(); } @@ -789,10 +792,13 @@ mod update_folder_tests { // Check file has implicit tag use crate::tags::service::get_tags_on_file; 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].tag_id, Some(1)); - assert_eq!(file_tags[0].title, "tag1"); - assert_eq!(file_tags[0].implicit_from, Some(1)); + assert_eq!(file_tags[0], expected); cleanup(); } diff --git a/src/tags/repository.rs b/src/tags/repository.rs index 9af8069..38f6d18 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -172,30 +172,6 @@ fn tag_mapper(row: &rusqlite::Row) -> Result { Ok(repository::Tag { id, title }) } -/// Gets all descendant folder IDs recursively for a given folder -pub fn get_descendant_folders( - folder_id: u32, - con: &Connection, -) -> Result, rusqlite::Error> { - let mut pst = con.prepare(include_str!( - "../assets/queries/tags/get_descendant_folders.sql" - ))?; - let rows = pst.query_map(rusqlite::params![folder_id], |row| row.get(0))?; - rows.collect::, rusqlite::Error>>() -} - -/// Gets all descendant file IDs recursively for a given folder -pub fn get_descendant_files( - folder_id: u32, - con: &Connection, -) -> Result, rusqlite::Error> { - let mut pst = con.prepare(include_str!( - "../assets/queries/tags/get_descendant_files.sql" - ))?; - let rows = pst.query_map(rusqlite::params![folder_id], |row| row.get(0))?; - rows.collect::, rusqlite::Error>>() -} - /// Adds an implicit tag to a folder (won't add if already exists) pub fn add_implicit_tag_to_folder( tag_id: u32, @@ -218,12 +194,16 @@ pub fn upsert_implicit_tag_to_folder( con: &Connection, ) -> Result<(), rusqlite::Error> { // First delete any existing implicit tag - let delete_sql = "delete from TaggedItems where folderId = ?1 and tagId = ?2 and implicitFromId is not null"; - con.execute(delete_sql, rusqlite::params![folder_id, tag_id])?; + let mut delete_pst = con.prepare(include_str!( + "../assets/queries/tags/delete_implicit_tag_from_folder.sql" + ))?; + delete_pst.execute(rusqlite::params![folder_id, tag_id])?; // Then insert the new one - let insert_sql = "insert into TaggedItems(tagId, folderId, implicitFromId) values (?1, ?2, ?3)"; - con.execute(insert_sql, rusqlite::params![tag_id, folder_id, implicit_from_id])?; + let mut insert_pst = con.prepare(include_str!( + "../assets/queries/tags/add_implicit_tag_to_folder.sql" + ))?; + insert_pst.execute(rusqlite::params![tag_id, folder_id, implicit_from_id])?; Ok(()) } @@ -249,12 +229,16 @@ pub fn upsert_implicit_tag_to_file( con: &Connection, ) -> Result<(), rusqlite::Error> { // First delete any existing implicit tag - let delete_sql = "delete from TaggedItems where fileId = ?1 and tagId = ?2 and implicitFromId is not null"; - con.execute(delete_sql, rusqlite::params![file_id, tag_id])?; + let mut delete_pst = con.prepare(include_str!( + "../assets/queries/tags/delete_implicit_tag_from_file.sql" + ))?; + delete_pst.execute(rusqlite::params![file_id, tag_id])?; // Then insert the new one - let insert_sql = "insert into TaggedItems(tagId, fileId, implicitFromId) values (?1, ?2, ?3)"; - con.execute(insert_sql, rusqlite::params![tag_id, file_id, implicit_from_id])?; + let mut insert_pst = con.prepare(include_str!( + "../assets/queries/tags/add_implicit_tag_to_file.sql" + ))?; + insert_pst.execute(rusqlite::params![tag_id, file_id, implicit_from_id])?; Ok(()) } diff --git a/src/tags/service.rs b/src/tags/service.rs index a1394d5..e538e19 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -442,7 +442,7 @@ pub fn pass_tags_to_children(folder_id: u32) -> Result<(), TagRelationError> { }; // Get all descendant folders and files - let descendant_folders = match tag_repository::get_descendant_folders(folder_id, &con) { + let descendant_folders = match folder_repository::get_all_child_folder_ids(&vec![folder_id], &con) { Ok(folders) => folders, Err(e) => { log::error!( @@ -454,8 +454,11 @@ pub fn pass_tags_to_children(folder_id: u32) -> Result<(), TagRelationError> { } }; - let descendant_files = match tag_repository::get_descendant_files(folder_id, &con) { - Ok(files) => files, + // Get files from the folder and all its descendants + let mut all_folder_ids = vec![folder_id]; + all_folder_ids.extend(&descendant_folders); + 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{}", diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index c82bbd1..8600b54 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -367,3 +367,135 @@ mod get_tags_on_files_tests { cleanup(); } } + +mod implicit_tag_tests { + use crate::repository::open_connection; + use crate::tags::repository::{ + add_implicit_tag_to_file, add_implicit_tag_to_folder, upsert_implicit_tag_to_file, + upsert_implicit_tag_to_folder, get_tags_on_file, get_tags_on_folder, + }; + use crate::test::*; + + #[test] + fn add_implicit_tag_to_folder_works() { + 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_folder(tag_id, 2, 1, &con).unwrap(); + let tags = get_tags_on_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(); + } + + #[test] + fn add_implicit_tag_to_file_works() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + create_file_db_entry("file.txt", Some(1)); + let tag_id = create_tag_db_entry("test_tag"); + let con = open_connection(); + add_implicit_tag_to_file(tag_id, 1, 1, &con).unwrap(); + let tags = get_tags_on_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(); + } + + #[test] + fn upsert_implicit_tag_to_folder_replaces_existing() { + init_db_folder(); + 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 + let tag_id = create_tag_db_entry("test_tag"); + let con = open_connection(); + // Add implicit tag from folder 1 + add_implicit_tag_to_folder(tag_id, 3, 1, &con).unwrap(); + // Upsert to change it to folder 2 + upsert_implicit_tag_to_folder(tag_id, 3, 2, &con).unwrap(); + let tags = get_tags_on_folder(3, &con).unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].tag_id, tag_id); + assert_eq!(tags[0].implicit_from_id, Some(2)); + con.close().unwrap(); + cleanup(); + } + + #[test] + fn upsert_implicit_tag_to_file_replaces_existing() { + init_db_folder(); + create_folder_db_entry("grandparent", None); // id 1 + create_folder_db_entry("parent", Some(1)); // id 2 + create_file_db_entry("file.txt", Some(2)); + let tag_id = create_tag_db_entry("test_tag"); + let con = open_connection(); + // Add implicit tag from folder 1 + add_implicit_tag_to_file(tag_id, 1, 1, &con).unwrap(); + // Upsert to change it to folder 2 + upsert_implicit_tag_to_file(tag_id, 1, 2, &con).unwrap(); + let tags = get_tags_on_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(2)); + con.close().unwrap(); + cleanup(); + } +} + +mod remove_implicit_tags_tests { + use crate::repository::open_connection; + use crate::tags::repository::{ + add_implicit_tag_to_file, add_implicit_tag_to_folder, + remove_implicit_tags_from_files, remove_implicit_tags_from_folders, + get_tags_on_file, get_tags_on_folder, + }; + use crate::test::*; + + #[test] + fn remove_implicit_tags_from_folders_works() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + create_folder_db_entry("folder1", Some(1)); // id 2 + create_folder_db_entry("folder2", Some(1)); // id 3 + let tag_id = create_tag_db_entry("test_tag"); + let con = open_connection(); + add_implicit_tag_to_folder(tag_id, 2, 1, &con).unwrap(); + add_implicit_tag_to_folder(tag_id, 3, 1, &con).unwrap(); + // Remove tags inherited from folder 1 + remove_implicit_tags_from_folders(&[2, 3], tag_id, 1, &con).unwrap(); + let tags2 = get_tags_on_folder(2, &con).unwrap(); + let tags3 = get_tags_on_folder(3, &con).unwrap(); + assert_eq!(tags2.len(), 0); + assert_eq!(tags3.len(), 0); + con.close().unwrap(); + cleanup(); + } + + #[test] + fn remove_implicit_tags_from_files_works() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + create_file_db_entry("file1.txt", Some(1)); + create_file_db_entry("file2.txt", Some(1)); + let tag_id = create_tag_db_entry("test_tag"); + let con = open_connection(); + add_implicit_tag_to_file(tag_id, 1, 1, &con).unwrap(); + add_implicit_tag_to_file(tag_id, 2, 1, &con).unwrap(); + // Remove tags inherited from folder 1 + remove_implicit_tags_from_files(&[1, 2], tag_id, 1, &con).unwrap(); + let tags1 = get_tags_on_file(1, &con).unwrap(); + let tags2 = get_tags_on_file(2, &con).unwrap(); + assert_eq!(tags1.len(), 0); + assert_eq!(tags2.len(), 0); + con.close().unwrap(); + cleanup(); + } +} + From dfd0504e91ea60790c6788e9ec949decd353e9ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 01:28:18 +0000 Subject: [PATCH 23/61] Fix logic to preserve closest explicit tag inheritance - Enhanced add_tag_to_descendants to check for closer ancestors - File/folder now keeps implicit tag from closest ancestor, not distant ones - Removing tag from distant ancestor doesn't affect closer inheritance - Added 3 comprehensive tests for closest ancestor scenarios Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- src/tags/service.rs | 25 +++++++- src/tags/tests/service.rs | 120 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/src/tags/service.rs b/src/tags/service.rs index e538e19..03dda2f 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -784,7 +784,7 @@ fn find_ancestor_with_tag_for_file( Ok(None) } -/// Adds a tag to all descendants that don't already have it explicitly +/// Adds a tag to all descendants that don't already have it explicitly or from a closer ancestor fn add_tag_to_descendants( tag_id: u32, folder_id: u32, @@ -814,6 +814,17 @@ fn add_tag_to_descendants( continue; } + // Check if folder has this tag implicitly from a closer ancestor (descendant of current folder) + // If the implicit_from_id is in descendant_folders, it means it's closer than folder_id + if let Some(existing_implicit) = tags.iter().find(|t| t.tag_id == tag_id && t.implicit_from_id.is_some()) { + if let Some(implicit_from) = existing_implicit.implicit_from_id { + // If the folder already inherits from a descendant of current folder, keep it + if descendant_folders.contains(&implicit_from) { + continue; + } + } + } + // Add or update the implicit tag if let Err(e) = tag_repository::upsert_implicit_tag_to_folder(tag_id, *descendant_folder_id, folder_id, con) { log::error!( @@ -846,6 +857,18 @@ fn add_tag_to_descendants( continue; } + // Check if file has this tag implicitly from a closer ancestor (descendant folder of current folder) + // Get the file's parent folder and check if it's a descendant of folder_id + if let Some(existing_implicit) = tags.iter().find(|t| t.tag_id == tag_id && t.implicit_from_id.is_some()) { + if let Some(implicit_from) = existing_implicit.implicit_from_id { + // If the file already inherits from a descendant of current folder, keep it + // This includes the direct parent and any ancestor folders that are descendants of folder_id + if descendant_folders.contains(&implicit_from) { + continue; + } + } + } + // Add or update the implicit tag if let Err(e) = tag_repository::upsert_implicit_tag_to_file(tag_id, *descendant_file_id, folder_id, con) { log::error!( diff --git a/src/tags/tests/service.rs b/src/tags/tests/service.rs index 0bae8dc..3784adb 100644 --- a/src/tags/tests/service.rs +++ b/src/tags/tests/service.rs @@ -757,4 +757,124 @@ mod pass_tags_to_children_tests { 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 + use crate::test::create_tag_db_entry; + use crate::repository::open_connection; + use crate::tags::repository as tag_repository; + 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_children(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_children(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 + use crate::test::create_tag_db_entry; + use crate::repository::open_connection; + use crate::tags::repository as tag_repository; + 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_children(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_children(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 + use crate::test::create_tag_db_entry; + use crate::repository::open_connection; + use crate::tags::repository as tag_repository; + 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_children(1).unwrap(); + pass_tags_to_children(2).unwrap(); + pass_tags_to_children(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 + use crate::tags::service::update_folder_tags; + 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(); + } } From 7e766ea06ed2955c2ff9168900407503ad99ab0e Mon Sep 17 00:00:00 2001 From: ploiu Date: Fri, 21 Nov 2025 21:01:57 +0000 Subject: [PATCH 24/61] some updates --- .devcontainer/Dockerfile | 1 + .github/copilot-instructions.md | 50 +++-- ...for_file.sql => get_all_tags_for_file.sql} | 0 ...r_files.sql => get_all_tags_for_files.sql} | 0 ...folder.sql => get_all_tags_for_folder.sql} | 0 ...ql => remove_explicit_tag_from_folder.sql} | 0 .../tags/remove_implicit_tag_from_files.sql | 6 + .../tags/remove_implicit_tags_from_files.sql | 5 - .../remove_implicit_tags_from_folders.sql | 9 +- src/service/folder_service.rs | 12 +- src/service/search_service.rs | 2 +- src/tags/repository.rs | 209 +++++++++++------- src/tags/service.rs | 106 +++++---- src/tags/tests/repository.rs | 54 ++--- src/tags/tests/service.rs | 2 +- 15 files changed, 267 insertions(+), 189 deletions(-) rename src/assets/queries/tags/{get_tags_for_file.sql => get_all_tags_for_file.sql} (100%) rename src/assets/queries/tags/{get_tags_for_files.sql => get_all_tags_for_files.sql} (100%) rename src/assets/queries/tags/{get_tags_for_folder.sql => get_all_tags_for_folder.sql} (100%) rename src/assets/queries/tags/{remove_tag_from_folder.sql => remove_explicit_tag_from_folder.sql} (100%) create mode 100644 src/assets/queries/tags/remove_implicit_tag_from_files.sql delete mode 100644 src/assets/queries/tags/remove_implicit_tags_from_files.sql 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..bfe359f 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,9 @@ 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 +49,34 @@ 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 +85,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 +148,6 @@ instead do this: let x = 1; format!("x: {x}"); ``` + +# Sql files +each sql file needs to be associated with a repository-layer function with the same name diff --git a/src/assets/queries/tags/get_tags_for_file.sql b/src/assets/queries/tags/get_all_tags_for_file.sql similarity index 100% rename from src/assets/queries/tags/get_tags_for_file.sql rename to src/assets/queries/tags/get_all_tags_for_file.sql diff --git a/src/assets/queries/tags/get_tags_for_files.sql b/src/assets/queries/tags/get_all_tags_for_files.sql similarity index 100% rename from src/assets/queries/tags/get_tags_for_files.sql rename to src/assets/queries/tags/get_all_tags_for_files.sql diff --git a/src/assets/queries/tags/get_tags_for_folder.sql b/src/assets/queries/tags/get_all_tags_for_folder.sql similarity index 100% rename from src/assets/queries/tags/get_tags_for_folder.sql rename to src/assets/queries/tags/get_all_tags_for_folder.sql diff --git a/src/assets/queries/tags/remove_tag_from_folder.sql b/src/assets/queries/tags/remove_explicit_tag_from_folder.sql similarity index 100% rename from src/assets/queries/tags/remove_tag_from_folder.sql rename to src/assets/queries/tags/remove_explicit_tag_from_folder.sql diff --git a/src/assets/queries/tags/remove_implicit_tag_from_files.sql b/src/assets/queries/tags/remove_implicit_tag_from_files.sql new file mode 100644 index 0000000..10d451b --- /dev/null +++ b/src/assets/queries/tags/remove_implicit_tag_from_files.sql @@ -0,0 +1,6 @@ +-- Remove implicit tags from files where the tag is inherited from a specific folder +delete from + TaggedItems +where + tagId = ?1 + and implicitFromId = ?2 \ No newline at end of file diff --git a/src/assets/queries/tags/remove_implicit_tags_from_files.sql b/src/assets/queries/tags/remove_implicit_tags_from_files.sql deleted file mode 100644 index 9251ed0..0000000 --- a/src/assets/queries/tags/remove_implicit_tags_from_files.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Remove implicit tags from files where the tag is inherited from a specific folder -delete from TaggedItems -where fileId in (?1) - and tagId = ?1 - and implicitFromId = ?2 diff --git a/src/assets/queries/tags/remove_implicit_tags_from_folders.sql b/src/assets/queries/tags/remove_implicit_tags_from_folders.sql index f623092..b237a54 100644 --- a/src/assets/queries/tags/remove_implicit_tags_from_folders.sql +++ b/src/assets/queries/tags/remove_implicit_tags_from_folders.sql @@ -1,5 +1,6 @@ -- Remove implicit tags from folders where the tag is inherited from a specific folder -delete from TaggedItems -where folderId in (?1) - and tagId = ?1 - and implicitFromId = ?2 +delete from + TaggedItems +where + tagId = ?1 + and implicitFromId = ?2 \ No newline at end of file diff --git a/src/service/folder_service.rs b/src/service/folder_service.rs index c18e01a..d26a967 100644 --- a/src/service/folder_service.rs +++ b/src/service/folder_service.rs @@ -50,7 +50,7 @@ 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) { + match tag_repository::get_all_tags_on_folder(child.id.unwrap_or(0), &con) { Ok(t) => t.into_iter().map_into().collect(), Err(e) => { log::error!( @@ -167,11 +167,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() @@ -526,7 +524,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_on_files(file_ids, con) { Ok(res) => res, Err(e) => { log::error!( diff --git a/src/service/search_service.rs b/src/service/search_service.rs index 87ea7b8..e4b3c68 100644 --- a/src/service/search_service.rs +++ b/src/service/search_service.rs @@ -69,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_on_files( final_set.iter().map(|f| f.id).collect(), &con, ) { diff --git a/src/tags/repository.rs b/src/tags/repository.rs index 38f6d18..812a879 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -1,5 +1,6 @@ use std::{backtrace::Backtrace, collections::HashMap}; +use itertools::Itertools; use rusqlite::Connection; use crate::model::repository; @@ -67,6 +68,7 @@ pub fn delete_tag(id: u32, con: &Connection) -> Result<(), rusqlite::Error> { 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, @@ -78,11 +80,46 @@ pub fn add_explicit_tag_to_file( Ok(()) } -pub fn get_tags_on_file( +/// Adds an implicit tag to a file +/// +/// Parameters: +/// - `tag_id`: the id of the tag to add +/// - `file_id`: the id of the file to add the tag to +/// - `implicit_from_id`: the id of the folder that implicates the tag on the file +/// +/// ## Returns: +/// will return a rusqlite error if a database interaction fails +pub fn add_implicit_tag_to_file( + tag_id: u32, + file_id: u32, + implicit_from_id: u32, + con: &Connection, +) -> Result<(), rusqlite::Error> { + let mut pst = con.prepare(include_str!( + "../assets/queries/tags/add_implicit_tag_to_file.sql" + ))?; + pst.execute(rusqlite::params![tag_id, file_id, implicit_from_id])?; + 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 not tags, an empty vec is returned +pub fn get_all_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 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 { @@ -91,14 +128,28 @@ pub fn get_tags_on_file( Ok(tags) } -pub fn get_tags_on_files( +/// Retrieves all tags on all files passed, explicit or implied. +/// The returned value is a Map of file id => Vec<[`repository::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_on_file] +/// +pub fn get_all_tags_on_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_tags_for_files.sql"), + include_str!("../assets/queries/tags/get_all_tags_for_files.sql"), in_clause ); let mut pst = con.prepare(formatted_query.as_str())?; @@ -132,6 +183,45 @@ pub fn remove_explicit_tag_from_file( Ok(()) } +/// Removes a single implied tag from all files that the passed `implicit_from_id` implicates the tag on +/// +/// ## Parameters: +/// - `tag_id`: the tag to remove from those files +/// - `implicit_from_id`: the folder that was implicating the tag on the files +/// - `con`: a connection to the database. Must be closed by the caller +pub fn remove_implicit_tag_from_files( + tag_id: u32, + implicit_from_id: u32, + con: &Connection, +) -> Result<(), rusqlite::Error> { + let query = include_str!("../assets/queries/tags/remove_implicit_tag_from_files.sql"); + let mut pst = con.prepare(&query)?; + pst.execute(rusqlite::params![tag_id, implicit_from_id])?; + Ok(()) +} + +/// Updates or inserts an implicit tag on a file, replacing any existing implicit tag from a different ancestor +pub fn upsert_implicit_tag_to_file( + tag_id: u32, + file_id: u32, + implicit_from_id: u32, + con: &Connection, +) -> Result<(), rusqlite::Error> { + // First delete any existing implicit tag + let mut delete_pst = con.prepare(include_str!( + "../assets/queries/tags/delete_implicit_tag_from_file.sql" + ))?; + delete_pst.execute(rusqlite::params![file_id, tag_id])?; + + // Then insert the new one + let mut insert_pst = con.prepare(include_str!( + "../assets/queries/tags/add_implicit_tag_to_file.sql" + ))?; + insert_pst.execute(rusqlite::params![tag_id, file_id, implicit_from_id])?; + Ok(()) +} + +// ================= folder functions ================= pub fn add_explicit_tag_to_folder( folder_id: u32, tag_id: u32, @@ -142,36 +232,39 @@ pub fn add_explicit_tag_to_folder( Ok(()) } -pub fn get_tags_on_folder( +/// 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_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" + "../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>>() } -pub fn remove_tag_from_folder( +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_tag_from_folder.sql" + "../assets/queries/tags/remove_explicit_tag_from_folder.sql" ))?; pst.execute(rusqlite::params![folder_id, tag_id])?; Ok(()) } -/// maps a [`repository::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(repository::Tag { id, title }) -} - /// Adds an implicit tag to a folder (won't add if already exists) pub fn add_implicit_tag_to_folder( tag_id: u32, @@ -198,7 +291,7 @@ pub fn upsert_implicit_tag_to_folder( "../assets/queries/tags/delete_implicit_tag_from_folder.sql" ))?; delete_pst.execute(rusqlite::params![folder_id, tag_id])?; - + // Then insert the new one let mut insert_pst = con.prepare(include_str!( "../assets/queries/tags/add_implicit_tag_to_folder.sql" @@ -207,85 +300,24 @@ pub fn upsert_implicit_tag_to_folder( Ok(()) } -/// Adds an implicit tag to a file (won't add if already exists) -pub fn add_implicit_tag_to_file( - tag_id: u32, - file_id: u32, - implicit_from_id: u32, - con: &Connection, -) -> Result<(), rusqlite::Error> { - let mut pst = con.prepare(include_str!( - "../assets/queries/tags/add_implicit_tag_to_file.sql" - ))?; - pst.execute(rusqlite::params![tag_id, file_id, implicit_from_id])?; - Ok(()) -} - -/// Updates or inserts an implicit tag on a file, replacing any existing implicit tag from a different ancestor -pub fn upsert_implicit_tag_to_file( - tag_id: u32, - file_id: u32, - implicit_from_id: u32, - con: &Connection, -) -> Result<(), rusqlite::Error> { - // First delete any existing implicit tag - let mut delete_pst = con.prepare(include_str!( - "../assets/queries/tags/delete_implicit_tag_from_file.sql" - ))?; - delete_pst.execute(rusqlite::params![file_id, tag_id])?; - - // Then insert the new one - let mut insert_pst = con.prepare(include_str!( - "../assets/queries/tags/add_implicit_tag_to_file.sql" - ))?; - insert_pst.execute(rusqlite::params![tag_id, file_id, implicit_from_id])?; - Ok(()) -} - -/// Removes implicit tags from folders where inherited from a specific folder +/// Removes a single implicit tag from all folders that the passed `implicit_from_id` implicates the tag on +/// +/// ## Parameters: +/// - `tag_id`: the tag to remove +/// - `implicit_from_id`: the folder that implicates the tag that should be removed +/// - `con`: a connection to the database. Must be closed by the caller pub fn remove_implicit_tags_from_folders( - folder_ids: &[u32], tag_id: u32, implicit_from_id: u32, con: &Connection, ) -> Result<(), rusqlite::Error> { - if folder_ids.is_empty() { - return Ok(()); - } - let in_clause: String = folder_ids - .iter() - .map(|id| id.to_string()) - .collect::>() - .join(","); - let query = include_str!("../assets/queries/tags/remove_implicit_tags_from_folders.sql") - .replace("(?1)", &format!("({})", in_clause)); - let mut pst = con.prepare(&query)?; - pst.execute(rusqlite::params![tag_id, implicit_from_id])?; - Ok(()) -} - -/// Removes implicit tags from files where inherited from a specific folder -pub fn remove_implicit_tags_from_files( - file_ids: &[u32], - tag_id: u32, - implicit_from_id: u32, - con: &Connection, -) -> Result<(), rusqlite::Error> { - if file_ids.is_empty() { - return Ok(()); - } - let in_clause: String = file_ids - .iter() - .map(|id| id.to_string()) - .collect::>() - .join(","); - let query = include_str!("../assets/queries/tags/remove_implicit_tags_from_files.sql") - .replace("(?1)", &format!("({})", in_clause)); + let query = include_str!("../assets/queries/tags/remove_implicit_tags_from_folders.sql"); let mut pst = con.prepare(&query)?; pst.execute(rusqlite::params![tag_id, implicit_from_id])?; Ok(()) } +// ================= misc ================= /// 1. id /// 2. fileId /// 3. folderId @@ -309,3 +341,10 @@ fn tagged_item_mapper(row: &rusqlite::Row) -> Result Result { + let id: u32 = row.get(0)?; + let title: String = row.get(1)?; + Ok(repository::Tag { id, title }) +} diff --git a/src/tags/service.rs b/src/tags/service.rs index 03dda2f..d67ec9c 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -276,7 +276,8 @@ pub fn update_folder_tags( // 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.tag_id.unwrap(), &con) + 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{}", @@ -340,10 +341,10 @@ pub fn update_folder_tags( } con.close().unwrap(); - + // Propagate tag changes to all descendants pass_tags_to_children(folder_id)?; - + Ok(()) } @@ -358,7 +359,7 @@ pub fn get_tags_on_file(file_id: u32) -> Result, TagRelationE return Err(TagRelationError::FileNotFound); } let con: rusqlite::Connection = open_connection(); - let file_tags = match tag_repository::get_tags_on_file(file_id, &con) { + let file_tags = match tag_repository::get_all_tags_on_file(file_id, &con) { Ok(tags) => tags, Err(e) => { log::error!( @@ -385,7 +386,7 @@ pub fn get_tags_on_folder(folder_id: u32) -> Result, TagRelat return Err(TagRelationError::FileNotFound); } let con: rusqlite::Connection = open_connection(); - let db_tags = match tag_repository::get_tags_on_folder(folder_id, &con) { + let db_tags = match tag_repository::get_all_tags_on_folder(folder_id, &con) { Ok(tags) => tags, Err(e) => { log::error!( @@ -426,7 +427,7 @@ pub fn pass_tags_to_children(folder_id: u32) -> Result<(), TagRelationError> { let con = open_connection(); // Get all explicit tags on this folder - let folder_tags = match tag_repository::get_tags_on_folder(folder_id, &con) { + let folder_tags = match tag_repository::get_all_tags_on_folder(folder_id, &con) { Ok(tags) => tags .into_iter() .filter(|t| t.implicit_from_id.is_none()) @@ -442,7 +443,10 @@ pub fn pass_tags_to_children(folder_id: u32) -> Result<(), TagRelationError> { }; // Get all descendant folders and files - let descendant_folders = match folder_repository::get_all_child_folder_ids(&vec![folder_id], &con) { + let descendant_folders = match folder_repository::get_all_child_folder_ids( + &vec![folder_id], + &con, + ) { Ok(folders) => folders, Err(e) => { log::error!( @@ -457,7 +461,8 @@ pub fn pass_tags_to_children(folder_id: u32) -> Result<(), TagRelationError> { // Get files from the folder and all its descendants let mut all_folder_ids = vec![folder_id]; all_folder_ids.extend(&descendant_folders); - let descendant_files: Vec = match folder_repository::get_child_files(all_folder_ids, &con) { + 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!( @@ -487,9 +492,13 @@ pub fn pass_tags_to_children(folder_id: u32) -> Result<(), TagRelationError> { // Add implications for all tags the folder has for tag in folder_tags { - if let Err(e) = - add_tag_to_descendants(tag.tag_id, folder_id, &descendant_folders, &descendant_files, &con) - { + if let Err(e) = add_tag_to_descendants( + tag.tag_id, + folder_id, + &descendant_folders, + &descendant_files, + &con, + ) { con.close().unwrap(); return Err(e); } @@ -512,7 +521,7 @@ fn remove_orphaned_implications( // Check folders for folder in descendant_folders { - let tags = match tag_repository::get_tags_on_folder(*folder, con) { + let tags = match tag_repository::get_all_tags_on_folder(*folder, con) { Ok(t) => t, Err(e) => { log::error!( @@ -531,7 +540,7 @@ fn remove_orphaned_implications( // Check files for file in descendant_files { - let tags = match tag_repository::get_tags_on_file(*file, con) { + let tags = match tag_repository::get_all_tags_on_file(*file, con) { Ok(t) => t, Err(e) => { log::error!( @@ -552,12 +561,9 @@ fn remove_orphaned_implications( for tag_id in implied_tags { if !current_tag_ids.contains(&tag_id) { // Remove from folders - if let Err(e) = tag_repository::remove_implicit_tags_from_folders( - descendant_folders, - tag_id, - folder_id, - con, - ) { + if let Err(e) = + tag_repository::remove_implicit_tags_from_folders(tag_id, folder_id, con) + { log::error!( "Failed to remove implicit tag {tag_id} from descendant folders! Error is {e:?}\n{}", Backtrace::force_capture() @@ -566,12 +572,7 @@ fn remove_orphaned_implications( } // Remove from files - if let Err(e) = tag_repository::remove_implicit_tags_from_files( - descendant_files, - tag_id, - folder_id, - con, - ) { + if let Err(e) = tag_repository::remove_implicit_tag_from_files(tag_id, folder_id, con) { log::error!( "Failed to remove implicit tag {tag_id} from descendant files! Error is {e:?}\n{}", Backtrace::force_capture() @@ -607,7 +608,7 @@ fn re_inherit_from_ancestors( for folder_id in descendant_folders { if let Some(new_implicit_from) = find_ancestor_with_tag(*folder_id, tag_id, con)? { // Only re-inherit if the folder doesn't have the tag explicitly - let tags = match tag_repository::get_tags_on_folder(*folder_id, con) { + let tags = match tag_repository::get_all_tags_on_folder(*folder_id, con) { Ok(t) => t, Err(e) => { log::error!( @@ -621,9 +622,12 @@ fn re_inherit_from_ancestors( .iter() .any(|t| t.tag_id == tag_id && t.implicit_from_id.is_none()); if !has_explicit { - if let Err(e) = - tag_repository::add_implicit_tag_to_folder(tag_id, *folder_id, new_implicit_from, con) - { + if let Err(e) = tag_repository::add_implicit_tag_to_folder( + tag_id, + *folder_id, + new_implicit_from, + con, + ) { log::error!( "Failed to re-inherit tag {tag_id} to folder {folder_id}! Error is {e:?}\n{}", Backtrace::force_capture() @@ -638,7 +642,7 @@ fn re_inherit_from_ancestors( for file_id in descendant_files { if let Some(new_implicit_from) = find_ancestor_with_tag_for_file(*file_id, tag_id, con)? { // Only re-inherit if the file doesn't have the tag explicitly - let tags = match tag_repository::get_tags_on_file(*file_id, con) { + let tags = match tag_repository::get_all_tags_on_file(*file_id, con) { Ok(t) => t, Err(e) => { log::error!( @@ -652,9 +656,12 @@ fn re_inherit_from_ancestors( .iter() .any(|t| t.tag_id == tag_id && t.implicit_from_id.is_none()); if !has_explicit { - if let Err(e) = - tag_repository::add_implicit_tag_to_file(tag_id, *file_id, new_implicit_from, con) - { + if let Err(e) = tag_repository::add_implicit_tag_to_file( + tag_id, + *file_id, + new_implicit_from, + con, + ) { log::error!( "Failed to re-inherit tag {tag_id} to file {file_id}! Error is {e:?}\n{}", Backtrace::force_capture() @@ -691,7 +698,7 @@ fn find_ancestor_with_tag( // Walk up the parent chain while let Some(parent_id) = current_parent { // Check if this parent has the tag explicitly - let tags = match tag_repository::get_tags_on_folder(parent_id, con) { + let tags = match tag_repository::get_all_tags_on_folder(parent_id, con) { Ok(t) => t, Err(e) => { log::error!( @@ -749,7 +756,7 @@ fn find_ancestor_with_tag_for_file( // Walk up the folder parent chain while let Some(parent_id) = current_parent { // Check if this folder has the tag explicitly - let tags = match tag_repository::get_tags_on_folder(parent_id, con) { + let tags = match tag_repository::get_all_tags_on_folder(parent_id, con) { Ok(t) => t, Err(e) => { log::error!( @@ -794,7 +801,7 @@ fn add_tag_to_descendants( ) -> Result<(), TagRelationError> { // For each descendant folder, check if it should have this implicit tag for descendant_folder_id in descendant_folders { - let tags = match tag_repository::get_tags_on_folder(*descendant_folder_id, con) { + let tags = match tag_repository::get_all_tags_on_folder(*descendant_folder_id, con) { Ok(t) => t, Err(e) => { log::error!( @@ -809,14 +816,17 @@ fn add_tag_to_descendants( let has_explicit = tags .iter() .any(|t| t.tag_id == tag_id && t.implicit_from_id.is_none()); - + if has_explicit { continue; } // Check if folder has this tag implicitly from a closer ancestor (descendant of current folder) // If the implicit_from_id is in descendant_folders, it means it's closer than folder_id - if let Some(existing_implicit) = tags.iter().find(|t| t.tag_id == tag_id && t.implicit_from_id.is_some()) { + if let Some(existing_implicit) = tags + .iter() + .find(|t| t.tag_id == tag_id && t.implicit_from_id.is_some()) + { if let Some(implicit_from) = existing_implicit.implicit_from_id { // If the folder already inherits from a descendant of current folder, keep it if descendant_folders.contains(&implicit_from) { @@ -826,7 +836,12 @@ fn add_tag_to_descendants( } // Add or update the implicit tag - if let Err(e) = tag_repository::upsert_implicit_tag_to_folder(tag_id, *descendant_folder_id, folder_id, con) { + if let Err(e) = tag_repository::upsert_implicit_tag_to_folder( + tag_id, + *descendant_folder_id, + folder_id, + con, + ) { log::error!( "Failed to upsert implicit tag {tag_id} to folder {descendant_folder_id}! Error is {e:?}\n{}", Backtrace::force_capture() @@ -837,7 +852,7 @@ fn add_tag_to_descendants( // For each descendant file, check if it should have this implicit tag for descendant_file_id in descendant_files { - let tags = match tag_repository::get_tags_on_file(*descendant_file_id, con) { + let tags = match tag_repository::get_all_tags_on_file(*descendant_file_id, con) { Ok(t) => t, Err(e) => { log::error!( @@ -852,14 +867,17 @@ fn add_tag_to_descendants( let has_explicit = tags .iter() .any(|t| t.tag_id == tag_id && t.implicit_from_id.is_none()); - + if has_explicit { continue; } // Check if file has this tag implicitly from a closer ancestor (descendant folder of current folder) // Get the file's parent folder and check if it's a descendant of folder_id - if let Some(existing_implicit) = tags.iter().find(|t| t.tag_id == tag_id && t.implicit_from_id.is_some()) { + if let Some(existing_implicit) = tags + .iter() + .find(|t| t.tag_id == tag_id && t.implicit_from_id.is_some()) + { if let Some(implicit_from) = existing_implicit.implicit_from_id { // If the file already inherits from a descendant of current folder, keep it // This includes the direct parent and any ancestor folders that are descendants of folder_id @@ -870,7 +888,9 @@ fn add_tag_to_descendants( } // Add or update the implicit tag - if let Err(e) = tag_repository::upsert_implicit_tag_to_file(tag_id, *descendant_file_id, folder_id, con) { + if let Err(e) = + tag_repository::upsert_implicit_tag_to_file(tag_id, *descendant_file_id, folder_id, con) + { log::error!( "Failed to upsert implicit tag {tag_id} to file {descendant_file_id}! Error is {e:?}\n{}", Backtrace::force_capture() diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index 8600b54..d2da9a0 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -156,7 +156,7 @@ mod get_tag_on_file_tests { .unwrap(); add_explicit_tag_to_file(1, 1, &con).unwrap(); add_explicit_tag_to_file(1, 2, &con).unwrap(); - let res = get_tags_on_file(1, &con).unwrap(); + let res = get_all_tags_on_file(1, &con).unwrap(); con.close().unwrap(); assert_eq!( vec![ @@ -197,7 +197,7 @@ mod get_tag_on_file_tests { &con, ) .unwrap(); - let res = get_tags_on_file(1, &con).unwrap(); + let res = get_all_tags_on_file(1, &con).unwrap(); con.close().unwrap(); assert_eq!(Vec::::new(), res); cleanup(); @@ -230,7 +230,7 @@ mod remove_tag_from_file_tests { ) .unwrap(); remove_explicit_tag_from_file(1, 1, &con).unwrap(); - let tags = get_tags_on_file(1, &con).unwrap(); + let tags = get_all_tags_on_file(1, &con).unwrap(); con.close().unwrap(); assert_eq!(Vec::::new(), tags); cleanup(); @@ -241,7 +241,7 @@ mod get_tag_on_folder_tests { use crate::model::repository::{Folder, TaggedItem}; use crate::repository::folder_repository::create_folder; use crate::repository::open_connection; - use crate::tags::repository::{add_explicit_tag_to_folder, create_tag, get_tags_on_folder}; + use crate::tags::repository::{add_explicit_tag_to_folder, create_tag, get_all_tags_on_folder}; use crate::test::*; #[test] @@ -261,7 +261,7 @@ mod get_tag_on_folder_tests { .unwrap(); add_explicit_tag_to_folder(1, 1, &con).unwrap(); add_explicit_tag_to_folder(1, 2, &con).unwrap(); - let res = get_tags_on_folder(1, &con).unwrap(); + let res = get_all_tags_on_folder(1, &con).unwrap(); con.close().unwrap(); assert_eq!( vec![ @@ -299,7 +299,7 @@ mod get_tag_on_folder_tests { &con, ) .unwrap(); - let res = get_tags_on_folder(1, &con).unwrap(); + let res = get_all_tags_on_folder(1, &con).unwrap(); con.close().unwrap(); assert_eq!(Vec::::new(), res); cleanup(); @@ -310,7 +310,9 @@ mod remove_tag_from_folder_tests { use crate::model::repository::{Folder, TaggedItem}; use crate::repository::folder_repository::create_folder; use crate::repository::open_connection; - use crate::tags::repository::{create_tag, get_tags_on_folder, remove_tag_from_folder}; + use crate::tags::repository::{ + create_tag, get_all_tags_on_folder, remove_explicit_tag_from_folder, + }; use crate::test::{cleanup, init_db_folder}; #[test] @@ -327,8 +329,8 @@ mod remove_tag_from_folder_tests { &con, ) .unwrap(); - remove_tag_from_folder(1, 1, &con).unwrap(); - let tags = get_tags_on_folder(1, &con).unwrap(); + remove_explicit_tag_from_folder(1, 1, &con).unwrap(); + let tags = get_all_tags_on_folder(1, &con).unwrap(); con.close().unwrap(); assert_eq!(Vec::::new(), tags); cleanup(); @@ -339,7 +341,7 @@ mod get_tags_on_files_tests { use std::collections::HashMap; use crate::model::repository::TaggedItem; - use crate::tags::repository::get_tags_on_files; + use crate::tags::repository::get_all_tags_on_files; use crate::{repository::open_connection, test::*}; #[test] @@ -352,7 +354,7 @@ mod get_tags_on_files_tests { create_tag_file("tag2", 1); create_tag_file("tag3", 2); let con = open_connection(); - let res = get_tags_on_files(vec![1, 2, 3], &con).unwrap(); + let res = get_all_tags_on_files(vec![1, 2, 3], &con).unwrap(); con.close().unwrap(); #[rustfmt::skip] let expected = HashMap::from([ @@ -371,8 +373,8 @@ mod get_tags_on_files_tests { mod implicit_tag_tests { use crate::repository::open_connection; use crate::tags::repository::{ - add_implicit_tag_to_file, add_implicit_tag_to_folder, upsert_implicit_tag_to_file, - upsert_implicit_tag_to_folder, get_tags_on_file, get_tags_on_folder, + add_implicit_tag_to_file, add_implicit_tag_to_folder, get_all_tags_on_file, + get_all_tags_on_folder, upsert_implicit_tag_to_file, upsert_implicit_tag_to_folder, }; use crate::test::*; @@ -384,7 +386,7 @@ mod implicit_tag_tests { let tag_id = create_tag_db_entry("test_tag"); let con = open_connection(); add_implicit_tag_to_folder(tag_id, 2, 1, &con).unwrap(); - let tags = get_tags_on_folder(2, &con).unwrap(); + let tags = get_all_tags_on_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)); @@ -400,7 +402,7 @@ mod implicit_tag_tests { let tag_id = create_tag_db_entry("test_tag"); let con = open_connection(); add_implicit_tag_to_file(tag_id, 1, 1, &con).unwrap(); - let tags = get_tags_on_file(1, &con).unwrap(); + let tags = get_all_tags_on_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)); @@ -420,7 +422,7 @@ mod implicit_tag_tests { add_implicit_tag_to_folder(tag_id, 3, 1, &con).unwrap(); // Upsert to change it to folder 2 upsert_implicit_tag_to_folder(tag_id, 3, 2, &con).unwrap(); - let tags = get_tags_on_folder(3, &con).unwrap(); + let tags = get_all_tags_on_folder(3, &con).unwrap(); assert_eq!(tags.len(), 1); assert_eq!(tags[0].tag_id, tag_id); assert_eq!(tags[0].implicit_from_id, Some(2)); @@ -440,7 +442,7 @@ mod implicit_tag_tests { add_implicit_tag_to_file(tag_id, 1, 1, &con).unwrap(); // Upsert to change it to folder 2 upsert_implicit_tag_to_file(tag_id, 1, 2, &con).unwrap(); - let tags = get_tags_on_file(1, &con).unwrap(); + let tags = get_all_tags_on_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(2)); @@ -452,9 +454,8 @@ mod implicit_tag_tests { mod remove_implicit_tags_tests { use crate::repository::open_connection; use crate::tags::repository::{ - add_implicit_tag_to_file, add_implicit_tag_to_folder, - remove_implicit_tags_from_files, remove_implicit_tags_from_folders, - get_tags_on_file, get_tags_on_folder, + add_implicit_tag_to_file, add_implicit_tag_to_folder, get_all_tags_on_file, + get_all_tags_on_folder, remove_implicit_tag_from_files, remove_implicit_tags_from_folders, }; use crate::test::*; @@ -469,9 +470,9 @@ mod remove_implicit_tags_tests { add_implicit_tag_to_folder(tag_id, 2, 1, &con).unwrap(); add_implicit_tag_to_folder(tag_id, 3, 1, &con).unwrap(); // Remove tags inherited from folder 1 - remove_implicit_tags_from_folders(&[2, 3], tag_id, 1, &con).unwrap(); - let tags2 = get_tags_on_folder(2, &con).unwrap(); - let tags3 = get_tags_on_folder(3, &con).unwrap(); + remove_implicit_tags_from_folders(tag_id, 1, &con).unwrap(); + let tags2 = get_all_tags_on_folder(2, &con).unwrap(); + let tags3 = get_all_tags_on_folder(3, &con).unwrap(); assert_eq!(tags2.len(), 0); assert_eq!(tags3.len(), 0); con.close().unwrap(); @@ -489,13 +490,12 @@ mod remove_implicit_tags_tests { add_implicit_tag_to_file(tag_id, 1, 1, &con).unwrap(); add_implicit_tag_to_file(tag_id, 2, 1, &con).unwrap(); // Remove tags inherited from folder 1 - remove_implicit_tags_from_files(&[1, 2], tag_id, 1, &con).unwrap(); - let tags1 = get_tags_on_file(1, &con).unwrap(); - let tags2 = get_tags_on_file(2, &con).unwrap(); + remove_implicit_tag_from_files(tag_id, 1, &con).unwrap(); + let tags1 = get_all_tags_on_file(1, &con).unwrap(); + let tags2 = get_all_tags_on_file(2, &con).unwrap(); assert_eq!(tags1.len(), 0); assert_eq!(tags2.len(), 0); con.close().unwrap(); cleanup(); } } - diff --git a/src/tags/tests/service.rs b/src/tags/tests/service.rs index 3784adb..67d41b5 100644 --- a/src/tags/tests/service.rs +++ b/src/tags/tests/service.rs @@ -706,7 +706,7 @@ mod pass_tags_to_children_tests { use crate::repository::open_connection; use crate::tags::repository as tag_repository; let con = open_connection(); - tag_repository::remove_tag_from_folder(1, 1, &con).unwrap(); + tag_repository::remove_explicit_tag_from_folder(1, 1, &con).unwrap(); con.close().unwrap(); // Propagate the change From 63185513da7ffebfca7ed473864e49a0b50f9900 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 21:09:52 +0000 Subject: [PATCH 25/61] Extract delete operations into separate functions with tests - Created delete_implicit_tag_from_folder function - Created delete_implicit_tag_from_file function - Updated upsert functions to use the new delete functions - Added 2 comprehensive tests for delete operations Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- src/tags/repository.rs | 36 +++++++++++++++++++++++------- src/tags/tests/repository.rs | 43 ++++++++++++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/tags/repository.rs b/src/tags/repository.rs index 812a879..44e2716 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -200,6 +200,19 @@ pub fn remove_implicit_tag_from_files( Ok(()) } +/// Deletes an implicit tag from a file if it exists +pub fn delete_implicit_tag_from_file( + tag_id: u32, + file_id: u32, + con: &Connection, +) -> Result<(), rusqlite::Error> { + let mut pst = con.prepare(include_str!( + "../assets/queries/tags/delete_implicit_tag_from_file.sql" + ))?; + pst.execute(rusqlite::params![file_id, tag_id])?; + Ok(()) +} + /// Updates or inserts an implicit tag on a file, replacing any existing implicit tag from a different ancestor pub fn upsert_implicit_tag_to_file( tag_id: u32, @@ -208,10 +221,7 @@ pub fn upsert_implicit_tag_to_file( con: &Connection, ) -> Result<(), rusqlite::Error> { // First delete any existing implicit tag - let mut delete_pst = con.prepare(include_str!( - "../assets/queries/tags/delete_implicit_tag_from_file.sql" - ))?; - delete_pst.execute(rusqlite::params![file_id, tag_id])?; + delete_implicit_tag_from_file(tag_id, file_id, con)?; // Then insert the new one let mut insert_pst = con.prepare(include_str!( @@ -279,6 +289,19 @@ pub fn add_implicit_tag_to_folder( Ok(()) } +/// Deletes an implicit tag from a folder if it exists +pub fn delete_implicit_tag_from_folder( + tag_id: u32, + folder_id: u32, + con: &Connection, +) -> Result<(), rusqlite::Error> { + let mut pst = con.prepare(include_str!( + "../assets/queries/tags/delete_implicit_tag_from_folder.sql" + ))?; + pst.execute(rusqlite::params![folder_id, tag_id])?; + Ok(()) +} + /// Updates or inserts an implicit tag on a folder, replacing any existing implicit tag from a different ancestor pub fn upsert_implicit_tag_to_folder( tag_id: u32, @@ -287,10 +310,7 @@ pub fn upsert_implicit_tag_to_folder( con: &Connection, ) -> Result<(), rusqlite::Error> { // First delete any existing implicit tag - let mut delete_pst = con.prepare(include_str!( - "../assets/queries/tags/delete_implicit_tag_from_folder.sql" - ))?; - delete_pst.execute(rusqlite::params![folder_id, tag_id])?; + delete_implicit_tag_from_folder(tag_id, folder_id, con)?; // Then insert the new one let mut insert_pst = con.prepare(include_str!( diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index d2da9a0..44a6072 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -373,8 +373,9 @@ mod get_tags_on_files_tests { mod implicit_tag_tests { use crate::repository::open_connection; use crate::tags::repository::{ - add_implicit_tag_to_file, add_implicit_tag_to_folder, get_all_tags_on_file, - get_all_tags_on_folder, upsert_implicit_tag_to_file, upsert_implicit_tag_to_folder, + add_implicit_tag_to_file, add_implicit_tag_to_folder, delete_implicit_tag_from_file, + delete_implicit_tag_from_folder, get_all_tags_on_file, get_all_tags_on_folder, + upsert_implicit_tag_to_file, upsert_implicit_tag_to_folder, }; use crate::test::*; @@ -449,6 +450,44 @@ mod implicit_tag_tests { con.close().unwrap(); cleanup(); } + + #[test] + fn delete_implicit_tag_from_folder_works() { + 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 + add_implicit_tag_to_folder(tag_id, 2, 1, &con).unwrap(); + let tags = get_all_tags_on_folder(2, &con).unwrap(); + assert_eq!(tags.len(), 1); + // Delete the implicit tag + delete_implicit_tag_from_folder(tag_id, 2, &con).unwrap(); + let tags = get_all_tags_on_folder(2, &con).unwrap(); + assert_eq!(tags.len(), 0); + con.close().unwrap(); + cleanup(); + } + + #[test] + fn delete_implicit_tag_from_file_works() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + create_file_db_entry("file.txt", Some(1)); + let tag_id = create_tag_db_entry("test_tag"); + let con = open_connection(); + // Add implicit tag + add_implicit_tag_to_file(tag_id, 1, 1, &con).unwrap(); + let tags = get_all_tags_on_file(1, &con).unwrap(); + assert_eq!(tags.len(), 1); + // Delete the implicit tag + delete_implicit_tag_from_file(tag_id, 1, &con).unwrap(); + let tags = get_all_tags_on_file(1, &con).unwrap(); + assert_eq!(tags.len(), 0); + con.close().unwrap(); + cleanup(); + } } mod remove_implicit_tags_tests { From f096578fa2c62fa3b792027e32ae3b3b7fa4b037 Mon Sep 17 00:00:00 2001 From: ploiu Date: Sat, 22 Nov 2025 17:18:20 +0000 Subject: [PATCH 26/61] some updates --- .../tags/get_explicit_tags_for_file.sql | 13 +++++ .../tags/get_implicit_tags_for_file.sql | 13 +++++ src/service/folder_service.rs | 4 +- src/service/search_service.rs | 2 +- src/tags/mod.rs | 9 +++ src/tags/repository.rs | 35 ++++++++---- src/tags/service.rs | 22 ++++---- src/tags/tests/repository.rs | 56 ++++++++++--------- 8 files changed, 102 insertions(+), 52 deletions(-) create mode 100644 src/assets/queries/tags/get_explicit_tags_for_file.sql create mode 100644 src/assets/queries/tags/get_implicit_tags_for_file.sql 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_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/service/folder_service.rs b/src/service/folder_service.rs index d26a967..67e7357 100644 --- a/src/service/folder_service.rs +++ b/src/service/folder_service.rs @@ -50,7 +50,7 @@ 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_all_tags_on_folder(child.id.unwrap_or(0), &con) { + 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!( @@ -524,7 +524,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_all_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!( diff --git a/src/service/search_service.rs b/src/service/search_service.rs index e4b3c68..37c2387 100644 --- a/src/service/search_service.rs +++ b/src/service/search_service.rs @@ -69,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_all_tags_on_files( + let tag_mapping = match tag_repository::get_all_tags_for_files( final_set.iter().map(|f| f.id).collect(), &con, ) { diff --git a/src/tags/mod.rs b/src/tags/mod.rs index 42506c7..c832f5f 100644 --- a/src/tags/mod.rs +++ b/src/tags/mod.rs @@ -4,3 +4,12 @@ pub mod service; #[cfg(test)] mod tests; + +/// lists the different types of tags that can exist on a file or folder +#[derive(Eq, PartialEq)] +enum TagTypes { + /// The tag was individually set on the file or folder + Explicit, + /// the tag was individually set on an ancestor folder + Implicit, +} diff --git a/src/tags/repository.rs b/src/tags/repository.rs index 44e2716..03cc2af 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -3,7 +3,10 @@ use std::{backtrace::Backtrace, collections::HashMap}; use itertools::Itertools; use rusqlite::Connection; -use crate::model::repository; +use crate::{ + model::{repository, response::TaggedItemApi}, + tags::TagTypes, +}; /// creates a new tag in the database. This does not check if the tag already exists, /// so the caller must check that themselves @@ -113,7 +116,7 @@ pub fn add_implicit_tag_to_file( /// - `Err(rusqlite::Error)`: if there was an error during the database operation /// /// If the file doesn't exist or has not tags, an empty vec is returned -pub fn get_all_tags_on_file( +pub fn get_all_tags_for_file( file_id: u32, con: &Connection, ) -> Result, rusqlite::Error> { @@ -128,6 +131,20 @@ pub fn get_all_tags_on_file( Ok(tags) } +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<[`repository::TaggedItem`]>. Files without _any_ tags will not have an entry in the map /// @@ -142,7 +159,7 @@ pub fn get_all_tags_on_file( /// --- /// See also [get_all_tags_on_file] /// -pub fn get_all_tags_on_files( +pub fn get_all_tags_for_files( file_ids: Vec, con: &Connection, ) -> Result>, rusqlite::Error> { @@ -201,7 +218,7 @@ pub fn remove_implicit_tag_from_files( } /// Deletes an implicit tag from a file if it exists -pub fn delete_implicit_tag_from_file( +pub fn remove_implicit_tag_from_file( tag_id: u32, file_id: u32, con: &Connection, @@ -221,14 +238,10 @@ pub fn upsert_implicit_tag_to_file( con: &Connection, ) -> Result<(), rusqlite::Error> { // First delete any existing implicit tag - delete_implicit_tag_from_file(tag_id, file_id, con)?; + remove_implicit_tag_from_file(tag_id, file_id, con)?; // Then insert the new one - let mut insert_pst = con.prepare(include_str!( - "../assets/queries/tags/add_implicit_tag_to_file.sql" - ))?; - insert_pst.execute(rusqlite::params![tag_id, file_id, implicit_from_id])?; - Ok(()) + add_implicit_tag_to_file(tag_id, file_id, implicit_from_id, con) } // ================= folder functions ================= @@ -252,7 +265,7 @@ pub fn add_explicit_tag_to_folder( /// ## Returns: /// - `Ok(Vec)` if the tags were successfully retrieved /// - `Err(rusqlite::Error)` if there was a database error -pub fn get_all_tags_on_folder( +pub fn get_all_tags_for_folder( folder_id: u32, con: &Connection, ) -> Result, rusqlite::Error> { diff --git a/src/tags/service.rs b/src/tags/service.rs index d67ec9c..c1b83e5 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -359,7 +359,7 @@ pub fn get_tags_on_file(file_id: u32) -> Result, TagRelationE return Err(TagRelationError::FileNotFound); } let con: rusqlite::Connection = open_connection(); - let file_tags = match tag_repository::get_all_tags_on_file(file_id, &con) { + let file_tags = match tag_repository::get_all_tags_for_file(file_id, &con) { Ok(tags) => tags, Err(e) => { log::error!( @@ -386,7 +386,7 @@ pub fn get_tags_on_folder(folder_id: u32) -> Result, TagRelat return Err(TagRelationError::FileNotFound); } let con: rusqlite::Connection = open_connection(); - let db_tags = match tag_repository::get_all_tags_on_folder(folder_id, &con) { + let db_tags = match tag_repository::get_all_tags_for_folder(folder_id, &con) { Ok(tags) => tags, Err(e) => { log::error!( @@ -427,7 +427,7 @@ pub fn pass_tags_to_children(folder_id: u32) -> Result<(), TagRelationError> { let con = open_connection(); // Get all explicit tags on this folder - let folder_tags = match tag_repository::get_all_tags_on_folder(folder_id, &con) { + let folder_tags = match tag_repository::get_all_tags_for_folder(folder_id, &con) { Ok(tags) => tags .into_iter() .filter(|t| t.implicit_from_id.is_none()) @@ -521,7 +521,7 @@ fn remove_orphaned_implications( // Check folders for folder in descendant_folders { - let tags = match tag_repository::get_all_tags_on_folder(*folder, con) { + let tags = match tag_repository::get_all_tags_for_folder(*folder, con) { Ok(t) => t, Err(e) => { log::error!( @@ -540,7 +540,7 @@ fn remove_orphaned_implications( // Check files for file in descendant_files { - let tags = match tag_repository::get_all_tags_on_file(*file, con) { + let tags = match tag_repository::get_all_tags_for_file(*file, con) { Ok(t) => t, Err(e) => { log::error!( @@ -608,7 +608,7 @@ fn re_inherit_from_ancestors( for folder_id in descendant_folders { if let Some(new_implicit_from) = find_ancestor_with_tag(*folder_id, tag_id, con)? { // Only re-inherit if the folder doesn't have the tag explicitly - let tags = match tag_repository::get_all_tags_on_folder(*folder_id, con) { + let tags = match tag_repository::get_all_tags_for_folder(*folder_id, con) { Ok(t) => t, Err(e) => { log::error!( @@ -642,7 +642,7 @@ fn re_inherit_from_ancestors( for file_id in descendant_files { if let Some(new_implicit_from) = find_ancestor_with_tag_for_file(*file_id, tag_id, con)? { // Only re-inherit if the file doesn't have the tag explicitly - let tags = match tag_repository::get_all_tags_on_file(*file_id, con) { + let tags = match tag_repository::get_all_tags_for_file(*file_id, con) { Ok(t) => t, Err(e) => { log::error!( @@ -698,7 +698,7 @@ fn find_ancestor_with_tag( // Walk up the parent chain while let Some(parent_id) = current_parent { // Check if this parent has the tag explicitly - let tags = match tag_repository::get_all_tags_on_folder(parent_id, con) { + let tags = match tag_repository::get_all_tags_for_folder(parent_id, con) { Ok(t) => t, Err(e) => { log::error!( @@ -756,7 +756,7 @@ fn find_ancestor_with_tag_for_file( // Walk up the folder parent chain while let Some(parent_id) = current_parent { // Check if this folder has the tag explicitly - let tags = match tag_repository::get_all_tags_on_folder(parent_id, con) { + let tags = match tag_repository::get_all_tags_for_folder(parent_id, con) { Ok(t) => t, Err(e) => { log::error!( @@ -801,7 +801,7 @@ fn add_tag_to_descendants( ) -> Result<(), TagRelationError> { // For each descendant folder, check if it should have this implicit tag for descendant_folder_id in descendant_folders { - let tags = match tag_repository::get_all_tags_on_folder(*descendant_folder_id, con) { + let tags = match tag_repository::get_all_tags_for_folder(*descendant_folder_id, con) { Ok(t) => t, Err(e) => { log::error!( @@ -852,7 +852,7 @@ fn add_tag_to_descendants( // For each descendant file, check if it should have this implicit tag for descendant_file_id in descendant_files { - let tags = match tag_repository::get_all_tags_on_file(*descendant_file_id, con) { + let tags = match tag_repository::get_all_tags_for_file(*descendant_file_id, con) { Ok(t) => t, Err(e) => { log::error!( diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index 44a6072..6b1aacf 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -156,7 +156,7 @@ mod get_tag_on_file_tests { .unwrap(); add_explicit_tag_to_file(1, 1, &con).unwrap(); add_explicit_tag_to_file(1, 2, &con).unwrap(); - let res = get_all_tags_on_file(1, &con).unwrap(); + let res = get_all_tags_for_file(1, &con).unwrap(); con.close().unwrap(); assert_eq!( vec![ @@ -197,7 +197,7 @@ mod get_tag_on_file_tests { &con, ) .unwrap(); - let res = get_all_tags_on_file(1, &con).unwrap(); + let res = get_all_tags_for_file(1, &con).unwrap(); con.close().unwrap(); assert_eq!(Vec::::new(), res); cleanup(); @@ -230,7 +230,7 @@ mod remove_tag_from_file_tests { ) .unwrap(); remove_explicit_tag_from_file(1, 1, &con).unwrap(); - let tags = get_all_tags_on_file(1, &con).unwrap(); + let tags = get_all_tags_for_file(1, &con).unwrap(); con.close().unwrap(); assert_eq!(Vec::::new(), tags); cleanup(); @@ -241,7 +241,9 @@ mod get_tag_on_folder_tests { use crate::model::repository::{Folder, TaggedItem}; use crate::repository::folder_repository::create_folder; use crate::repository::open_connection; - use crate::tags::repository::{add_explicit_tag_to_folder, create_tag, get_all_tags_on_folder}; + use crate::tags::repository::{ + add_explicit_tag_to_folder, create_tag, get_all_tags_for_folder, + }; use crate::test::*; #[test] @@ -261,7 +263,7 @@ mod get_tag_on_folder_tests { .unwrap(); add_explicit_tag_to_folder(1, 1, &con).unwrap(); add_explicit_tag_to_folder(1, 2, &con).unwrap(); - let res = get_all_tags_on_folder(1, &con).unwrap(); + let res = get_all_tags_for_folder(1, &con).unwrap(); con.close().unwrap(); assert_eq!( vec![ @@ -299,7 +301,7 @@ mod get_tag_on_folder_tests { &con, ) .unwrap(); - let res = get_all_tags_on_folder(1, &con).unwrap(); + let res = get_all_tags_for_folder(1, &con).unwrap(); con.close().unwrap(); assert_eq!(Vec::::new(), res); cleanup(); @@ -311,7 +313,7 @@ mod remove_tag_from_folder_tests { use crate::repository::folder_repository::create_folder; use crate::repository::open_connection; use crate::tags::repository::{ - create_tag, get_all_tags_on_folder, remove_explicit_tag_from_folder, + create_tag, get_all_tags_for_folder, remove_explicit_tag_from_folder, }; use crate::test::{cleanup, init_db_folder}; @@ -330,7 +332,7 @@ mod remove_tag_from_folder_tests { ) .unwrap(); remove_explicit_tag_from_folder(1, 1, &con).unwrap(); - let tags = get_all_tags_on_folder(1, &con).unwrap(); + let tags = get_all_tags_for_folder(1, &con).unwrap(); con.close().unwrap(); assert_eq!(Vec::::new(), tags); cleanup(); @@ -341,7 +343,7 @@ mod get_tags_on_files_tests { use std::collections::HashMap; use crate::model::repository::TaggedItem; - use crate::tags::repository::get_all_tags_on_files; + use crate::tags::repository::get_all_tags_for_files; use crate::{repository::open_connection, test::*}; #[test] @@ -354,7 +356,7 @@ mod get_tags_on_files_tests { create_tag_file("tag2", 1); create_tag_file("tag3", 2); let con = open_connection(); - let res = get_all_tags_on_files(vec![1, 2, 3], &con).unwrap(); + let res = get_all_tags_for_files(vec![1, 2, 3], &con).unwrap(); con.close().unwrap(); #[rustfmt::skip] let expected = HashMap::from([ @@ -373,8 +375,8 @@ mod get_tags_on_files_tests { mod implicit_tag_tests { use crate::repository::open_connection; use crate::tags::repository::{ - add_implicit_tag_to_file, add_implicit_tag_to_folder, delete_implicit_tag_from_file, - delete_implicit_tag_from_folder, get_all_tags_on_file, get_all_tags_on_folder, + add_implicit_tag_to_file, add_implicit_tag_to_folder, delete_implicit_tag_from_folder, + get_all_tags_for_file, get_all_tags_for_folder, remove_implicit_tag_from_file, upsert_implicit_tag_to_file, upsert_implicit_tag_to_folder, }; use crate::test::*; @@ -387,7 +389,7 @@ mod implicit_tag_tests { let tag_id = create_tag_db_entry("test_tag"); let con = open_connection(); add_implicit_tag_to_folder(tag_id, 2, 1, &con).unwrap(); - let tags = get_all_tags_on_folder(2, &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)); @@ -403,7 +405,7 @@ mod implicit_tag_tests { let tag_id = create_tag_db_entry("test_tag"); let con = open_connection(); add_implicit_tag_to_file(tag_id, 1, 1, &con).unwrap(); - let tags = get_all_tags_on_file(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)); @@ -423,7 +425,7 @@ mod implicit_tag_tests { add_implicit_tag_to_folder(tag_id, 3, 1, &con).unwrap(); // Upsert to change it to folder 2 upsert_implicit_tag_to_folder(tag_id, 3, 2, &con).unwrap(); - let tags = get_all_tags_on_folder(3, &con).unwrap(); + let tags = get_all_tags_for_folder(3, &con).unwrap(); assert_eq!(tags.len(), 1); assert_eq!(tags[0].tag_id, tag_id); assert_eq!(tags[0].implicit_from_id, Some(2)); @@ -443,7 +445,7 @@ mod implicit_tag_tests { add_implicit_tag_to_file(tag_id, 1, 1, &con).unwrap(); // Upsert to change it to folder 2 upsert_implicit_tag_to_file(tag_id, 1, 2, &con).unwrap(); - let tags = get_all_tags_on_file(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(2)); @@ -460,11 +462,11 @@ mod implicit_tag_tests { let con = open_connection(); // Add implicit tag add_implicit_tag_to_folder(tag_id, 2, 1, &con).unwrap(); - let tags = get_all_tags_on_folder(2, &con).unwrap(); + let tags = get_all_tags_for_folder(2, &con).unwrap(); assert_eq!(tags.len(), 1); // Delete the implicit tag delete_implicit_tag_from_folder(tag_id, 2, &con).unwrap(); - let tags = get_all_tags_on_folder(2, &con).unwrap(); + let tags = get_all_tags_for_folder(2, &con).unwrap(); assert_eq!(tags.len(), 0); con.close().unwrap(); cleanup(); @@ -479,11 +481,11 @@ mod implicit_tag_tests { let con = open_connection(); // Add implicit tag add_implicit_tag_to_file(tag_id, 1, 1, &con).unwrap(); - let tags = get_all_tags_on_file(1, &con).unwrap(); + let tags = get_all_tags_for_file(1, &con).unwrap(); assert_eq!(tags.len(), 1); // Delete the implicit tag - delete_implicit_tag_from_file(tag_id, 1, &con).unwrap(); - let tags = get_all_tags_on_file(1, &con).unwrap(); + remove_implicit_tag_from_file(tag_id, 1, &con).unwrap(); + let tags = get_all_tags_for_file(1, &con).unwrap(); assert_eq!(tags.len(), 0); con.close().unwrap(); cleanup(); @@ -493,8 +495,8 @@ mod implicit_tag_tests { mod remove_implicit_tags_tests { use crate::repository::open_connection; use crate::tags::repository::{ - add_implicit_tag_to_file, add_implicit_tag_to_folder, get_all_tags_on_file, - get_all_tags_on_folder, remove_implicit_tag_from_files, remove_implicit_tags_from_folders, + add_implicit_tag_to_file, add_implicit_tag_to_folder, get_all_tags_for_file, + get_all_tags_for_folder, remove_implicit_tag_from_files, remove_implicit_tags_from_folders, }; use crate::test::*; @@ -510,8 +512,8 @@ mod remove_implicit_tags_tests { add_implicit_tag_to_folder(tag_id, 3, 1, &con).unwrap(); // Remove tags inherited from folder 1 remove_implicit_tags_from_folders(tag_id, 1, &con).unwrap(); - let tags2 = get_all_tags_on_folder(2, &con).unwrap(); - let tags3 = get_all_tags_on_folder(3, &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(); @@ -530,8 +532,8 @@ mod remove_implicit_tags_tests { add_implicit_tag_to_file(tag_id, 2, 1, &con).unwrap(); // Remove tags inherited from folder 1 remove_implicit_tag_from_files(tag_id, 1, &con).unwrap(); - let tags1 = get_all_tags_on_file(1, &con).unwrap(); - let tags2 = get_all_tags_on_file(2, &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(); From ac98d29916a9c0c1cea51260fc23c42e9c3d8b14 Mon Sep 17 00:00:00 2001 From: ploiu Date: Sat, 22 Nov 2025 18:27:58 +0000 Subject: [PATCH 27/61] some refactoring --- src/model/repository/mod.rs | 29 -------------------- src/model/response/mod.rs | 10 +++---- src/tags/mod.rs | 1 + src/tags/models.rs | 28 +++++++++++++++++++ src/tags/repository.rs | 52 +++++++++++++++++------------------- src/tags/service.rs | 12 ++++----- src/tags/tests/repository.rs | 22 ++++++++------- src/test/mod.rs | 3 ++- 8 files changed, 80 insertions(+), 77 deletions(-) create mode 100644 src/tags/models.rs diff --git a/src/model/repository/mod.rs b/src/model/repository/mod.rs index f7987be..523d3dd 100644 --- a/src/model/repository/mod.rs +++ b/src/model/repository/mod.rs @@ -32,35 +32,6 @@ pub struct Folder { pub parent_id: Option, } -/// 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, -} - impl From<&FileApi> for FileRecord { fn from(value: &FileApi) -> Self { let create_date = value diff --git a/src/model/response/mod.rs b/src/model/response/mod.rs index d31fb20..336e97f 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::models::{Tag, TaggedItem}; pub mod api_responses; pub mod file_responses; @@ -66,8 +66,8 @@ 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, @@ -75,8 +75,8 @@ impl From for TagApi { } } -impl From for TaggedItemApi { - fn from(value: repository::TaggedItem) -> Self { +impl From for TaggedItemApi { + fn from(value: TaggedItem) -> Self { Self { tag_id: Some(value.tag_id), title: value.title, diff --git a/src/tags/mod.rs b/src/tags/mod.rs index c832f5f..ab4d097 100644 --- a/src/tags/mod.rs +++ b/src/tags/mod.rs @@ -1,4 +1,5 @@ pub mod handler; +pub mod models; pub mod repository; pub mod service; diff --git a/src/tags/models.rs b/src/tags/models.rs new file mode 100644 index 0000000..892cdff --- /dev/null +++ b/src/tags/models.rs @@ -0,0 +1,28 @@ +/// 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 index 03cc2af..214fb33 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -1,19 +1,17 @@ use std::{backtrace::Backtrace, collections::HashMap}; -use itertools::Itertools; use rusqlite::Connection; -use crate::{ - model::{repository, response::TaggedItemApi}, - tags::TagTypes, -}; +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 { +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 { + Ok(models::Tag { id, title: title.to_string(), }) @@ -25,7 +23,7 @@ pub fn create_tag(title: &str, con: &Connection) -> Result Result, rusqlite::Error> { +) -> 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)), @@ -51,15 +49,15 @@ pub fn get_tag_by_title( /// - `con`: the database connection to use. Callers must handle closing the connection /// /// # Returns -/// - `Ok(repository::Tag)`: the tag with the specified ID if the tag exists +/// - `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 { +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> { +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(()) @@ -112,19 +110,19 @@ pub fn add_implicit_tag_to_file( /// - `con` a reference to a database connection. This must be closed by the parent /// /// ## Returns: -/// - `Ok(Vec)`: a list of tags on the file +/// - `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 not tags, an empty vec is returned pub fn get_all_tags_for_file( file_id: u32, con: &Connection, -) -> Result, rusqlite::Error> { +) -> 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(); + let mut tags: Vec = Vec::new(); for tag_res in rows { tags.push(tag_res?); } @@ -135,7 +133,7 @@ pub fn get_tags_for_file( file_id: u32, tag_type: TagTypes, con: &Connection, -) -> Result, rusqlite::Error> { +) -> 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"), @@ -146,14 +144,14 @@ pub fn get_tags_for_file( } /// Retrieves all tags on all files passed, explicit or implied. -/// The returned value is a Map of file id => Vec<[`repository::TaggedItem`]>. Files without _any_ tags will not have an entry in the map +/// 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 +/// - `Ok(HashMap>)` if the tags were successfully retrieved /// - `Err(rusqlite::Error)` if there was a database error /// /// --- @@ -162,7 +160,7 @@ pub fn get_tags_for_file( pub fn get_all_tags_for_files( file_ids: Vec, con: &Connection, -) -> Result>, rusqlite::Error> { +) -> 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!( @@ -171,7 +169,7 @@ pub fn get_all_tags_for_files( ); let mut pst = con.prepare(formatted_query.as_str())?; let rows = pst.query_map([], tagged_item_mapper)?; - let mut mapped: HashMap> = HashMap::new(); + let mut mapped: HashMap> = HashMap::new(); for tag in rows { let tag = tag?; let id = tag @@ -263,17 +261,17 @@ pub fn add_explicit_tag_to_folder( /// - `con` a reference to a database connection. The caller must manage closing the connection. /// /// ## Returns: -/// - `Ok(Vec)` if the tags were successfully retrieved +/// - `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> { +) -> 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>>() + rows.collect::, rusqlite::Error>>() } pub fn remove_explicit_tag_from_folder( @@ -357,7 +355,7 @@ pub fn remove_implicit_tags_from_folders( /// 4. implicitFromId /// 5. tagId /// 6. title -fn tagged_item_mapper(row: &rusqlite::Row) -> Result { +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)?; @@ -365,7 +363,7 @@ fn tagged_item_mapper(row: &rusqlite::Row) -> Result Result Result { +/// 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(repository::Tag { id, title }) + Ok(models::Tag { id, title }) } diff --git a/src/tags/service.rs b/src/tags/service.rs index c1b83e5..e2228e3 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -8,18 +8,18 @@ use crate::model::error::file_errors::GetFileError; use crate::model::error::tag_errors::{ CreateTagError, DeleteTagError, GetTagError, TagRelationError, UpdateTagError, }; -use crate::model::repository::{self}; use crate::model::response::{TagApi, TaggedItemApi}; use crate::repository::{folder_repository, open_connection}; use crate::service::{file_service, folder_service}; use crate::tags::repository as tag_repository; +use super::models; + /// 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) - { + let existing_tag: Option = match tag_repository::get_tag_by_title(&name, &con) { Ok(tags) => tags, Err(e) => { log::error!( @@ -30,7 +30,7 @@ pub fn create_tag(name: String) -> Result { return Err(CreateTagError::DbError); } }; - let tag: repository::Tag = if let Some(t) = existing_tag { + let tag: models::Tag = if let Some(t) = existing_tag { t } else { match tag_repository::create_tag(&name, &con) { @@ -53,7 +53,7 @@ pub fn create_tag(name: String) -> Result { /// 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) { + let tag: models::Tag = match tag_repository::get_tag(id, &con) { Ok(t) => t, Err(rusqlite::Error::QueryReturnedNoRows) => { log::error!( @@ -126,7 +126,7 @@ pub fn update_tag(request: TagApi) -> Result { } }; // no match, and tag already exists so we're good to go - let db_tag = repository::Tag { + let db_tag = models::Tag { id: request.id.unwrap(), title: new_title.clone(), }; diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index 6b1aacf..28c8e45 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -1,6 +1,6 @@ mod create_tag_tests { - use crate::model::repository::Tag; use crate::repository::open_connection; + use crate::tags::models::Tag; use crate::tags::repository; use crate::test::{cleanup, init_db_folder}; @@ -22,8 +22,8 @@ mod create_tag_tests { } mod get_tag_by_title_tests { - use crate::model::repository::Tag; use crate::repository::open_connection; + use crate::tags::models::Tag; use crate::tags::repository::{create_tag, get_tag_by_title}; use crate::test::*; @@ -55,8 +55,8 @@ mod get_tag_by_title_tests { } mod get_tag_by_id_tests { - use crate::model::repository::Tag; use crate::repository::open_connection; + use crate::tags::models::Tag; use crate::tags::repository::{create_tag, get_tag}; use crate::test::{cleanup, init_db_folder}; @@ -79,8 +79,8 @@ mod get_tag_by_id_tests { } mod update_tag_tests { - use crate::model::repository::Tag; use crate::repository::open_connection; + use crate::tags::models::Tag; use crate::tags::repository::{create_tag, get_tag, update_tag}; use crate::test::{cleanup, init_db_folder}; @@ -130,9 +130,10 @@ mod delete_tag_tests { mod get_tag_on_file_tests { use crate::model::file_types::FileTypes; - use crate::model::repository::{FileRecord, TaggedItem}; + use crate::model::repository::FileRecord; use crate::repository::file_repository::create_file; use crate::repository::open_connection; + use crate::tags::models::TaggedItem; use crate::tags::repository::*; use crate::test::*; @@ -206,9 +207,10 @@ mod get_tag_on_file_tests { mod remove_tag_from_file_tests { use crate::model::file_types::FileTypes; - use crate::model::repository::{FileRecord, TaggedItem}; + use crate::model::repository::FileRecord; use crate::repository::file_repository::create_file; use crate::repository::open_connection; + use crate::tags::models::TaggedItem; use crate::tags::repository::*; use crate::test::{cleanup, init_db_folder, now}; @@ -238,9 +240,10 @@ mod remove_tag_from_file_tests { } mod get_tag_on_folder_tests { - use crate::model::repository::{Folder, TaggedItem}; + use crate::model::repository::Folder; use crate::repository::folder_repository::create_folder; use crate::repository::open_connection; + use crate::tags::models::TaggedItem; use crate::tags::repository::{ add_explicit_tag_to_folder, create_tag, get_all_tags_for_folder, }; @@ -309,9 +312,10 @@ mod get_tag_on_folder_tests { } mod remove_tag_from_folder_tests { - use crate::model::repository::{Folder, TaggedItem}; + use crate::model::repository::Folder; use crate::repository::folder_repository::create_folder; use crate::repository::open_connection; + use crate::tags::models::TaggedItem; use crate::tags::repository::{ create_tag, get_all_tags_for_folder, remove_explicit_tag_from_folder, }; @@ -342,7 +346,7 @@ mod remove_tag_from_folder_tests { mod get_tags_on_files_tests { use std::collections::HashMap; - use crate::model::repository::TaggedItem; + use crate::tags::models::TaggedItem; use crate::tags::repository::get_all_tags_for_files; use crate::{repository::open_connection, test::*}; diff --git a/src/test/mod.rs b/src/test/mod.rs index 23f4e87..5d9d661 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -8,10 +8,11 @@ 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}; use crate::service::file_service::{determine_file_type, file_dir}; + use crate::tags::models::Tag; use crate::tags::repository as tag_repository; use crate::temp_dir; use rocket::local::blocking::Client; From a7abdcf0f95200530b4b2c1168f07ecf951e425f Mon Sep 17 00:00:00 2001 From: ploiu Date: Sun, 23 Nov 2025 02:34:10 +0000 Subject: [PATCH 28/61] some more heavy refactoring --- .../folder/get_ancestor_folders_with_id.sql | 30 ++ .../folder/get_parent_folders_with_id.sql | 10 - .../tags/get_explicit_tags_for_folder.sql | 13 + .../tags/get_implicit_tags_for_folder.sql | 13 + ....sql => remove_implicit_tag_from_file.sql} | 0 ...ql => remove_implicit_tag_from_folder.sql} | 0 ...e_stale_implicit_tags_from_descendants.sql | 14 + src/model/response/mod.rs | 2 +- src/repository/folder_repository.rs | 66 +++ src/tags/mod.rs | 10 +- src/tags/models.rs | 9 + src/tags/repository.rs | 104 +++- src/tags/service.rs | 481 ++---------------- src/tags/tests/repository.rs | 24 +- src/tags/tests/service.rs | 16 +- src/test/mod.rs | 2 +- 16 files changed, 287 insertions(+), 507 deletions(-) create mode 100644 src/assets/queries/folder/get_ancestor_folders_with_id.sql delete mode 100644 src/assets/queries/folder/get_parent_folders_with_id.sql create mode 100644 src/assets/queries/tags/get_explicit_tags_for_folder.sql create mode 100644 src/assets/queries/tags/get_implicit_tags_for_folder.sql rename src/assets/queries/tags/{delete_implicit_tag_from_file.sql => remove_implicit_tag_from_file.sql} (100%) rename src/assets/queries/tags/{delete_implicit_tag_from_folder.sql => remove_implicit_tag_from_folder.sql} (100%) create mode 100644 src/assets/queries/tags/remove_stale_implicit_tags_from_descendants.sql 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_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/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_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/delete_implicit_tag_from_file.sql b/src/assets/queries/tags/remove_implicit_tag_from_file.sql similarity index 100% rename from src/assets/queries/tags/delete_implicit_tag_from_file.sql rename to src/assets/queries/tags/remove_implicit_tag_from_file.sql diff --git a/src/assets/queries/tags/delete_implicit_tag_from_folder.sql b/src/assets/queries/tags/remove_implicit_tag_from_folder.sql similarity index 100% rename from src/assets/queries/tags/delete_implicit_tag_from_folder.sql rename to src/assets/queries/tags/remove_implicit_tag_from_folder.sql 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/model/response/mod.rs b/src/model/response/mod.rs index 336e97f..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::tags::models::{Tag, TaggedItem}; +use crate::tags::{Tag, TaggedItem}; pub mod api_responses; pub mod file_responses; diff --git a/src/repository/folder_repository.rs b/src/repository/folder_repository.rs index ad9031c..dcb700e 100644 --- a/src/repository/folder_repository.rs +++ b/src/repository/folder_repository.rs @@ -209,6 +209,30 @@ pub fn get_all_child_folder_ids + Clone>( Ok(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_ancestor_folders_with_id.sql" + ))?; + // 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) +} + fn map_folder(row: &rusqlite::Row) -> Result { let id: Option = row.get(0)?; let name: String = row.get(1)?; @@ -304,3 +328,45 @@ mod get_child_files_tests { cleanup(); } } + +#[cfg(test)] +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 should_return_empty_vec_if_no_parents() { + init_db_folder(); + create_folder_db_entry("top", None); + let con = open_connection(); + let res = get_ancestor_folders_with_id(1, &con).unwrap(); + con.close().unwrap(); + assert!(res.is_empty()); + cleanup(); + } + + #[test] + fn should_return_empty_vec_if_folder_does_not_exist() { + init_db_folder(); + let con = open_connection(); + let res = get_ancestor_folders_with_id(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_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(); + let res = get_ancestor_folders_with_id(4, &con).unwrap(); + con.close().unwrap(); + assert_eq!(vec![3, 2, 1], res); + cleanup(); + } +} diff --git a/src/tags/mod.rs b/src/tags/mod.rs index ab4d097..a693eb5 100644 --- a/src/tags/mod.rs +++ b/src/tags/mod.rs @@ -6,11 +6,5 @@ pub mod service; #[cfg(test)] mod tests; -/// lists the different types of tags that can exist on a file or folder -#[derive(Eq, PartialEq)] -enum TagTypes { - /// The tag was individually set on the file or folder - Explicit, - /// the tag was individually set on an ancestor folder - Implicit, -} +// make it easier to just use models +pub use models::*; diff --git a/src/tags/models.rs b/src/tags/models.rs index 892cdff..82d888a 100644 --- a/src/tags/models.rs +++ b/src/tags/models.rs @@ -1,3 +1,12 @@ +/// 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 { diff --git a/src/tags/repository.rs b/src/tags/repository.rs index 214fb33..7abfa55 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -129,6 +129,15 @@ pub fn get_all_tags_for_file( 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, @@ -222,7 +231,7 @@ pub fn remove_implicit_tag_from_file( con: &Connection, ) -> Result<(), rusqlite::Error> { let mut pst = con.prepare(include_str!( - "../assets/queries/tags/delete_implicit_tag_from_file.sql" + "../assets/queries/tags/remove_implicit_tag_from_file.sql" ))?; pst.execute(rusqlite::params![file_id, tag_id])?; Ok(()) @@ -253,6 +262,20 @@ pub fn add_explicit_tag_to_folder( Ok(()) } +/// Adds an implicit tag to a folder (won't add if already exists) +pub fn add_implicit_tag_to_folder( + tag_id: u32, + folder_id: 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" + ))?; + 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. /// @@ -274,6 +297,33 @@ pub fn get_all_tags_for_folder( 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, @@ -286,30 +336,33 @@ pub fn remove_explicit_tag_from_folder( Ok(()) } -/// Adds an implicit tag to a folder (won't add if already exists) -pub fn add_implicit_tag_to_folder( +/// Deletes an implicit tag from a folder if it exists +pub fn remove_implicit_tag_from_folder( tag_id: u32, folder_id: 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" + "../assets/queries/tags/remove_implicit_tag_from_folder.sql" ))?; - pst.execute(rusqlite::params![tag_id, folder_id, implicit_from_id])?; + pst.execute(rusqlite::params![folder_id, tag_id])?; Ok(()) } -/// Deletes an implicit tag from a folder if it exists -pub fn delete_implicit_tag_from_folder( +/// Removes a single implicit tag from all folders that the passed `implicit_from_id` implicates the tag on +/// +/// ## Parameters: +/// - `tag_id`: the tag to remove +/// - `implicit_from_id`: the folder that implicates the tag that should be removed +/// - `con`: a connection to the database. Must be closed by the caller +pub fn remove_implicit_tags_from_folders( tag_id: u32, - folder_id: u32, + implicit_from_id: u32, con: &Connection, ) -> Result<(), rusqlite::Error> { - let mut pst = con.prepare(include_str!( - "../assets/queries/tags/delete_implicit_tag_from_folder.sql" - ))?; - pst.execute(rusqlite::params![folder_id, tag_id])?; + let query = include_str!("../assets/queries/tags/remove_implicit_tags_from_folders.sql"); + let mut pst = con.prepare(&query)?; + pst.execute(rusqlite::params![tag_id, implicit_from_id])?; Ok(()) } @@ -321,7 +374,7 @@ pub fn upsert_implicit_tag_to_folder( con: &Connection, ) -> Result<(), rusqlite::Error> { // First delete any existing implicit tag - delete_implicit_tag_from_folder(tag_id, folder_id, con)?; + remove_implicit_tag_from_folder(tag_id, folder_id, con)?; // Then insert the new one let mut insert_pst = con.prepare(include_str!( @@ -331,21 +384,24 @@ pub fn upsert_implicit_tag_to_folder( Ok(()) } -/// Removes a single implicit tag from all folders that the passed `implicit_from_id` implicates the tag on +// ================= 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: -/// - `tag_id`: the tag to remove -/// - `implicit_from_id`: the folder that implicates the tag that should be removed +/// - `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_implicit_tags_from_folders( - tag_id: u32, - implicit_from_id: u32, +pub fn remove_stale_implicit_tags_from_descendants( + implied_from_id: u32, con: &Connection, ) -> Result<(), rusqlite::Error> { - let query = include_str!("../assets/queries/tags/remove_implicit_tags_from_folders.sql"); - let mut pst = con.prepare(&query)?; - pst.execute(rusqlite::params![tag_id, implicit_from_id])?; - Ok(()) + let mut pst = con.prepare(include_str!( + "../assets/queries/tags/remove_stale_implicit_tags_from_descendants.sql" + ))?; + pst.execute([implied_from_id]).and(Ok(())) } // ================= misc ================= diff --git a/src/tags/service.rs b/src/tags/service.rs index e2228e3..e07c02b 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -2,8 +2,8 @@ use std::backtrace::Backtrace; use std::collections::HashSet; use itertools::Itertools; -use rusqlite::Connection; +use super::{Tag, TagTypes}; use crate::model::error::file_errors::GetFileError; use crate::model::error::tag_errors::{ CreateTagError, DeleteTagError, GetTagError, TagRelationError, UpdateTagError, @@ -11,15 +11,14 @@ use crate::model::error::tag_errors::{ use crate::model::response::{TagApi, TaggedItemApi}; use crate::repository::{folder_repository, open_connection}; use crate::service::{file_service, folder_service}; +use crate::tags::repository; use crate::tags::repository as tag_repository; -use super::models; - /// 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) { + let existing_tag: Option = match tag_repository::get_tag_by_title(&name, &con) { Ok(tags) => tags, Err(e) => { log::error!( @@ -30,7 +29,7 @@ pub fn create_tag(name: String) -> Result { return Err(CreateTagError::DbError); } }; - let tag: models::Tag = if let Some(t) = existing_tag { + let tag: Tag = if let Some(t) = existing_tag { t } else { match tag_repository::create_tag(&name, &con) { @@ -53,7 +52,7 @@ pub fn create_tag(name: String) -> Result { /// will return the tag with the passed id pub fn get_tag(id: u32) -> Result { let con = open_connection(); - let tag: models::Tag = match tag_repository::get_tag(id, &con) { + let tag: Tag = match tag_repository::get_tag(id, &con) { Ok(t) => t, Err(rusqlite::Error::QueryReturnedNoRows) => { log::error!( @@ -126,7 +125,7 @@ pub fn update_tag(request: TagApi) -> Result { } }; // no match, and tag already exists so we're good to go - let db_tag = models::Tag { + let db_tag = Tag { id: request.id.unwrap(), title: new_title.clone(), }; @@ -427,23 +426,21 @@ pub fn pass_tags_to_children(folder_id: u32) -> Result<(), TagRelationError> { let con = open_connection(); // Get all explicit tags on this folder - let folder_tags = match tag_repository::get_all_tags_for_folder(folder_id, &con) { - Ok(tags) => tags - .into_iter() - .filter(|t| t.implicit_from_id.is_none()) - .collect::>(), - Err(e) => { - log::error!( - "Failed to retrieve tags on folder {folder_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(TagRelationError::DbError); - } - }; + let explicit_tags = + match tag_repository::get_tags_for_folder(folder_id, TagTypes::Explicit, &con) { + Ok(tags) => tags, + Err(e) => { + log::error!( + "Failed to retrieve tags on folder {folder_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(TagRelationError::DbError); + } + }; - // Get all descendant folders and files - let descendant_folders = match folder_repository::get_all_child_folder_ids( + // 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( &vec![folder_id], &con, ) { @@ -457,10 +454,8 @@ pub fn pass_tags_to_children(folder_id: u32) -> Result<(), TagRelationError> { return Err(TagRelationError::DbError); } }; - - // Get files from the folder and all its descendants - let mut all_folder_ids = vec![folder_id]; - all_folder_ids.extend(&descendant_folders); + // 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(), @@ -474,430 +469,30 @@ pub fn pass_tags_to_children(folder_id: u32) -> Result<(), TagRelationError> { } }; - // Get all tag IDs that this folder has explicitly - let folder_tag_ids: HashSet = folder_tags.iter().map(|t| t.tag_id).collect(); - - // Remove all implicit tags from descendants that the folder doesn't have - // This handles the case where a tag was removed from the folder - if let Err(e) = remove_orphaned_implications( - folder_id, - &descendant_folders, - &descendant_files, - &folder_tag_ids, - &con, - ) { + // 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(); - return Err(e); - } - - // Add implications for all tags the folder has - for tag in folder_tags { - if let Err(e) = add_tag_to_descendants( - tag.tag_id, - folder_id, - &descendant_folders, - &descendant_files, - &con, - ) { - con.close().unwrap(); - return Err(e); - } - } - - con.close().unwrap(); - Ok(()) -} - -/// Removes implicit tags from descendants that are inherited from this folder but the folder no longer has -fn remove_orphaned_implications( - folder_id: u32, - descendant_folders: &[u32], - descendant_files: &[u32], - current_tag_ids: &HashSet, - con: &Connection, -) -> Result<(), TagRelationError> { - // Get all unique tag IDs that are currently implied from this folder to any descendant - let mut implied_tags: HashSet = HashSet::new(); - - // Check folders - for folder in descendant_folders { - let tags = match tag_repository::get_all_tags_for_folder(*folder, con) { - Ok(t) => t, - Err(e) => { - log::error!( - "Failed to retrieve tags on folder {folder}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::DbError); - } - }; - for tag in tags { - if tag.implicit_from_id == Some(folder_id) { - implied_tags.insert(tag.tag_id); - } - } - } - - // Check files - for file in descendant_files { - let tags = match tag_repository::get_all_tags_for_file(*file, con) { - Ok(t) => t, - Err(e) => { - log::error!( - "Failed to retrieve tags on file {file}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::DbError); - } - }; - for tag in tags { - if tag.implicit_from_id == Some(folder_id) { - implied_tags.insert(tag.tag_id); - } - } - } - - // Remove implications for tags that are no longer on the folder - for tag_id in implied_tags { - if !current_tag_ids.contains(&tag_id) { - // Remove from folders - if let Err(e) = - tag_repository::remove_implicit_tags_from_folders(tag_id, folder_id, con) - { - log::error!( - "Failed to remove implicit tag {tag_id} from descendant folders! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::DbError); - } - - // Remove from files - if let Err(e) = tag_repository::remove_implicit_tag_from_files(tag_id, folder_id, con) { - log::error!( - "Failed to remove implicit tag {tag_id} from descendant files! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::DbError); - } - - // After removing the tag, check if any descendant needs to re-inherit from a higher ancestor - if let Err(e) = re_inherit_from_ancestors( - folder_id, - tag_id, - descendant_folders, - descendant_files, - con, - ) { - return Err(e); - } - } - } - - Ok(()) -} - -/// After removing an implicit tag, check if descendants need to inherit it from a higher ancestor -fn re_inherit_from_ancestors( - _removed_from_folder_id: u32, - tag_id: u32, - descendant_folders: &[u32], - descendant_files: &[u32], - con: &Connection, -) -> Result<(), TagRelationError> { - // For each descendant folder, walk up the parent chain to find if any ancestor has this tag - for folder_id in descendant_folders { - if let Some(new_implicit_from) = find_ancestor_with_tag(*folder_id, tag_id, con)? { - // Only re-inherit if the folder doesn't have the tag explicitly - let tags = match tag_repository::get_all_tags_for_folder(*folder_id, con) { - Ok(t) => t, - Err(e) => { - log::error!( - "Failed to get tags for folder {folder_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::DbError); - } - }; - let has_explicit = tags - .iter() - .any(|t| t.tag_id == tag_id && t.implicit_from_id.is_none()); - if !has_explicit { - if let Err(e) = tag_repository::add_implicit_tag_to_folder( - tag_id, - *folder_id, - new_implicit_from, - con, - ) { - log::error!( - "Failed to re-inherit tag {tag_id} to folder {folder_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::DbError); - } - } - } - } - - // For each descendant file, walk up the parent chain to find if any ancestor has this tag - for file_id in descendant_files { - if let Some(new_implicit_from) = find_ancestor_with_tag_for_file(*file_id, tag_id, con)? { - // Only re-inherit if the file doesn't have the tag explicitly - let tags = match tag_repository::get_all_tags_for_file(*file_id, con) { - Ok(t) => t, - Err(e) => { - log::error!( - "Failed to get tags for file {file_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::DbError); - } - }; - let has_explicit = tags - .iter() - .any(|t| t.tag_id == tag_id && t.implicit_from_id.is_none()); - if !has_explicit { - if let Err(e) = tag_repository::add_implicit_tag_to_file( - tag_id, - *file_id, - new_implicit_from, - con, - ) { - log::error!( - "Failed to re-inherit tag {tag_id} to file {file_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::DbError); - } - } - } - } - - Ok(()) -} - -/// Finds the nearest ancestor folder that has the specified tag explicitly -fn find_ancestor_with_tag( - folder_id: u32, - tag_id: u32, - con: &Connection, -) -> Result, TagRelationError> { - // Get the folder to find its parent - let folder = match folder_repository::get_by_id(Some(folder_id), con) { - Ok(f) => f, - Err(e) => { - log::error!( - "Failed to get folder {folder_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::DbError); - } - }; - - let mut current_parent = folder.parent_id; - - // Walk up the parent chain - while let Some(parent_id) = current_parent { - // Check if this parent has the tag explicitly - let tags = match tag_repository::get_all_tags_for_folder(parent_id, con) { - Ok(t) => t, - Err(e) => { - log::error!( - "Failed to get tags for folder {parent_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::DbError); - } - }; - - if tags - .iter() - .any(|t| t.tag_id == tag_id && t.implicit_from_id.is_none()) - { - return Ok(Some(parent_id)); - } - - // Move to the next parent - let parent = match folder_repository::get_by_id(Some(parent_id), con) { - Ok(f) => f, - Err(e) => { - log::error!( - "Failed to get folder {parent_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::DbError); - } - }; - current_parent = parent.parent_id; + log::error!( + "Failed to remove implicit tags from descendants of folder {folder_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); } - - Ok(None) -} - -/// Finds the nearest ancestor folder of a file that has the specified tag explicitly -fn find_ancestor_with_tag_for_file( - file_id: u32, - tag_id: u32, - con: &Connection, -) -> Result, TagRelationError> { - // Get the file's parent folder - let file_record = match file_service::get_file_metadata(file_id) { - Ok(f) => f, + // 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 all_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 get file {file_id}! Error is {e:?}\n{}", + "Failed to retrieve ancestor folders for folder {folder_id}! Error is {e:?}\n{}", Backtrace::force_capture() ); return Err(TagRelationError::DbError); } }; - - let mut current_parent = file_record.folder_id; - - // Walk up the folder parent chain - while let Some(parent_id) = current_parent { - // Check if this folder has the tag explicitly - let tags = match tag_repository::get_all_tags_for_folder(parent_id, con) { - Ok(t) => t, - Err(e) => { - log::error!( - "Failed to get tags for folder {parent_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::DbError); - } - }; - - if tags - .iter() - .any(|t| t.tag_id == tag_id && t.implicit_from_id.is_none()) - { - return Ok(Some(parent_id)); - } - - // Move to the next parent - let parent = match folder_repository::get_by_id(Some(parent_id), con) { - Ok(f) => f, - Err(e) => { - log::error!( - "Failed to get folder {parent_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::DbError); - } - }; - current_parent = parent.parent_id; - } - - Ok(None) -} - -/// Adds a tag to all descendants that don't already have it explicitly or from a closer ancestor -fn add_tag_to_descendants( - tag_id: u32, - folder_id: u32, - descendant_folders: &[u32], - descendant_files: &[u32], - con: &Connection, -) -> Result<(), TagRelationError> { - // For each descendant folder, check if it should have this implicit tag - for descendant_folder_id in descendant_folders { - let tags = match tag_repository::get_all_tags_for_folder(*descendant_folder_id, con) { - Ok(t) => t, - Err(e) => { - log::error!( - "Failed to get tags for folder {descendant_folder_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::DbError); - } - }; - - // Check if folder has this tag explicitly - if so, don't override - let has_explicit = tags - .iter() - .any(|t| t.tag_id == tag_id && t.implicit_from_id.is_none()); - - if has_explicit { - continue; - } - - // Check if folder has this tag implicitly from a closer ancestor (descendant of current folder) - // If the implicit_from_id is in descendant_folders, it means it's closer than folder_id - if let Some(existing_implicit) = tags - .iter() - .find(|t| t.tag_id == tag_id && t.implicit_from_id.is_some()) - { - if let Some(implicit_from) = existing_implicit.implicit_from_id { - // If the folder already inherits from a descendant of current folder, keep it - if descendant_folders.contains(&implicit_from) { - continue; - } - } - } - - // Add or update the implicit tag - if let Err(e) = tag_repository::upsert_implicit_tag_to_folder( - tag_id, - *descendant_folder_id, - folder_id, - con, - ) { - log::error!( - "Failed to upsert implicit tag {tag_id} to folder {descendant_folder_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::DbError); - } - } - - // For each descendant file, check if it should have this implicit tag - for descendant_file_id in descendant_files { - let tags = match tag_repository::get_all_tags_for_file(*descendant_file_id, con) { - Ok(t) => t, - Err(e) => { - log::error!( - "Failed to get tags for file {descendant_file_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::DbError); - } - }; - - // Check if file has this tag explicitly - if so, don't override - let has_explicit = tags - .iter() - .any(|t| t.tag_id == tag_id && t.implicit_from_id.is_none()); - - if has_explicit { - continue; - } - - // Check if file has this tag implicitly from a closer ancestor (descendant folder of current folder) - // Get the file's parent folder and check if it's a descendant of folder_id - if let Some(existing_implicit) = tags - .iter() - .find(|t| t.tag_id == tag_id && t.implicit_from_id.is_some()) - { - if let Some(implicit_from) = existing_implicit.implicit_from_id { - // If the file already inherits from a descendant of current folder, keep it - // This includes the direct parent and any ancestor folders that are descendants of folder_id - if descendant_folders.contains(&implicit_from) { - continue; - } - } - } - - // Add or update the implicit tag - if let Err(e) = - tag_repository::upsert_implicit_tag_to_file(tag_id, *descendant_file_id, folder_id, con) - { - log::error!( - "Failed to upsert implicit tag {tag_id} to file {descendant_file_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - return Err(TagRelationError::DbError); - } - } - + // TODO get all tags for ancestor IDs, get the explicit ones, and make all children inherit them (use insert or ignore) + con.close().unwrap(); Ok(()) } diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index 28c8e45..6c3ead5 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -1,6 +1,6 @@ mod create_tag_tests { use crate::repository::open_connection; - use crate::tags::models::Tag; + use crate::tags::Tag; use crate::tags::repository; use crate::test::{cleanup, init_db_folder}; @@ -23,7 +23,7 @@ mod create_tag_tests { mod get_tag_by_title_tests { use crate::repository::open_connection; - use crate::tags::models::Tag; + use crate::tags::Tag; use crate::tags::repository::{create_tag, get_tag_by_title}; use crate::test::*; @@ -56,7 +56,7 @@ mod get_tag_by_title_tests { mod get_tag_by_id_tests { use crate::repository::open_connection; - use crate::tags::models::Tag; + use crate::tags::Tag; use crate::tags::repository::{create_tag, get_tag}; use crate::test::{cleanup, init_db_folder}; @@ -80,7 +80,7 @@ mod get_tag_by_id_tests { mod update_tag_tests { use crate::repository::open_connection; - use crate::tags::models::Tag; + use crate::tags::Tag; use crate::tags::repository::{create_tag, get_tag, update_tag}; use crate::test::{cleanup, init_db_folder}; @@ -133,7 +133,7 @@ mod get_tag_on_file_tests { use crate::model::repository::FileRecord; use crate::repository::file_repository::create_file; use crate::repository::open_connection; - use crate::tags::models::TaggedItem; + use crate::tags::TaggedItem; use crate::tags::repository::*; use crate::test::*; @@ -210,7 +210,7 @@ mod remove_tag_from_file_tests { use crate::model::repository::FileRecord; use crate::repository::file_repository::create_file; use crate::repository::open_connection; - use crate::tags::models::TaggedItem; + use crate::tags::TaggedItem; use crate::tags::repository::*; use crate::test::{cleanup, init_db_folder, now}; @@ -243,7 +243,7 @@ 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::models::TaggedItem; + use crate::tags::TaggedItem; use crate::tags::repository::{ add_explicit_tag_to_folder, create_tag, get_all_tags_for_folder, }; @@ -315,7 +315,7 @@ 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::models::TaggedItem; + use crate::tags::TaggedItem; use crate::tags::repository::{ create_tag, get_all_tags_for_folder, remove_explicit_tag_from_folder, }; @@ -346,7 +346,7 @@ mod remove_tag_from_folder_tests { mod get_tags_on_files_tests { use std::collections::HashMap; - use crate::tags::models::TaggedItem; + use crate::tags::TaggedItem; use crate::tags::repository::get_all_tags_for_files; use crate::{repository::open_connection, test::*}; @@ -379,8 +379,8 @@ mod get_tags_on_files_tests { mod implicit_tag_tests { use crate::repository::open_connection; use crate::tags::repository::{ - add_implicit_tag_to_file, add_implicit_tag_to_folder, delete_implicit_tag_from_folder, - get_all_tags_for_file, get_all_tags_for_folder, remove_implicit_tag_from_file, + add_implicit_tag_to_file, add_implicit_tag_to_folder, get_all_tags_for_file, + get_all_tags_for_folder, remove_implicit_tag_from_file, remove_implicit_tag_from_folder, upsert_implicit_tag_to_file, upsert_implicit_tag_to_folder, }; use crate::test::*; @@ -469,7 +469,7 @@ mod implicit_tag_tests { let tags = get_all_tags_for_folder(2, &con).unwrap(); assert_eq!(tags.len(), 1); // Delete the implicit tag - delete_implicit_tag_from_folder(tag_id, 2, &con).unwrap(); + remove_implicit_tag_from_folder(tag_id, 2, &con).unwrap(); let tags = get_all_tags_for_folder(2, &con).unwrap(); assert_eq!(tags.len(), 0); con.close().unwrap(); diff --git a/src/tags/tests/service.rs b/src/tags/tests/service.rs index 67d41b5..70b5b38 100644 --- a/src/tags/tests/service.rs +++ b/src/tags/tests/service.rs @@ -555,7 +555,7 @@ mod get_tags_on_folder_tests { } mod pass_tags_to_children_tests { - use crate::model::response::TaggedItemApi; + use crate::tags::service::{get_tags_on_file, get_tags_on_folder, pass_tags_to_children}; use crate::test::{ cleanup, create_file_db_entry, create_folder_db_entry, create_tag_folder, init_db_folder, @@ -634,9 +634,9 @@ mod pass_tags_to_children_tests { create_folder_db_entry("child", Some(1)); // id 2 // Create tag and add explicitly to both - use crate::test::create_tag_db_entry; use crate::repository::open_connection; use crate::tags::repository as tag_repository; + use crate::test::create_tag_db_entry; 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(); @@ -664,9 +664,9 @@ mod pass_tags_to_children_tests { create_file_db_entry("file.txt", Some(1)); // id 1 // Create tag and add explicitly to both - use crate::test::create_tag_db_entry; use crate::repository::open_connection; use crate::tags::repository as tag_repository; + use crate::test::create_tag_db_entry; 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(); @@ -728,9 +728,9 @@ mod pass_tags_to_children_tests { create_folder_db_entry("child", Some(2)); // id 3 // Create tag and add explicitly to both grandparent and parent - use crate::test::create_tag_db_entry; use crate::repository::open_connection; use crate::tags::repository as tag_repository; + use crate::test::create_tag_db_entry; 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(); @@ -767,11 +767,11 @@ mod pass_tags_to_children_tests { create_folder_db_entry("bottom", Some(2)); // id 3 // Create tag once - use crate::test::create_tag_db_entry; use crate::repository::open_connection; use crate::tags::repository as tag_repository; + use crate::test::create_tag_db_entry; 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(); @@ -802,9 +802,9 @@ mod pass_tags_to_children_tests { create_file_db_entry("file.png", Some(3)); // Create tag once - use crate::test::create_tag_db_entry; use crate::repository::open_connection; use crate::tags::repository as tag_repository; + use crate::test::create_tag_db_entry; let tag_id = create_tag_db_entry("test_tag"); // Add tag to bottom @@ -841,9 +841,9 @@ mod pass_tags_to_children_tests { create_folder_db_entry("bottom", Some(2)); // id 3 // Add tag to all three levels - use crate::test::create_tag_db_entry; use crate::repository::open_connection; use crate::tags::repository as tag_repository; + use crate::test::create_tag_db_entry; 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(); diff --git a/src/test/mod.rs b/src/test/mod.rs index 5d9d661..8d9351e 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -12,7 +12,7 @@ mod tests { use crate::previews; use crate::repository::{file_repository, folder_repository, initialize_db, open_connection}; use crate::service::file_service::{determine_file_type, file_dir}; - use crate::tags::models::Tag; + use crate::tags::Tag; use crate::tags::repository as tag_repository; use crate::temp_dir; use rocket::local::blocking::Client; From 8de0281bc1a6dbc4d037c0d18244c9a4a382fd65 Mon Sep 17 00:00:00 2001 From: ploiu Date: Sun, 23 Nov 2025 14:53:53 +0000 Subject: [PATCH 29/61] rewrite most of copilot's garbage --- src/previews/service.rs | 2 +- src/repository/folder_repository.rs | 10 +- src/service/file_service.rs | 2 +- src/service/folder_service.rs | 8 +- src/tags/repository.rs | 94 ++++++++++++------ src/tags/service.rs | 77 +++++++++++---- src/tags/tests/repository.rs | 147 ++++++++++++++++++++-------- src/tags/tests/service.rs | 38 ++----- src/test/folder_handler_tests.rs | 2 +- 9 files changed, 245 insertions(+), 135 deletions(-) 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/folder_repository.rs b/src/repository/folder_repository.rs index dcb700e..2c67fee 100644 --- a/src/repository/folder_repository.rs +++ b/src/repository/folder_repository.rs @@ -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.into_iter().copied().collect(); if ids.is_empty() { get_child_files_root(con) } else { @@ -293,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) @@ -315,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) diff --git a/src/service/file_service.rs b/src/service/file_service.rs index 8aa102b..6f50797 100644 --- a/src/service/file_service.rs +++ b/src/service/file_service.rs @@ -627,7 +627,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); diff --git a/src/service/folder_service.rs b/src/service/folder_service.rs index 67e7357..669f72a 100644 --- a/src/service/folder_service.rs +++ b/src/service/folder_service.rs @@ -201,7 +201,7 @@ pub async fn get_file_previews_for_folder( ) -> 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(); @@ -478,7 +478,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(); @@ -510,7 +510,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!( @@ -562,7 +562,7 @@ fn delete_folder_recursively(id: u32, con: &Connection) -> Result {} diff --git a/src/tags/repository.rs b/src/tags/repository.rs index 7abfa55..01c4cef 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -83,6 +83,8 @@ pub fn add_explicit_tag_to_file( /// Adds an implicit tag to a file /// +/// This function will _only_ add a tag to a file if it doesn't already have that tag (explicit or implicit) +/// /// Parameters: /// - `tag_id`: the id of the tag to add /// - `file_id`: the id of the file to add the tag to @@ -103,6 +105,36 @@ pub fn add_implicit_tag_to_file( 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 +/// - `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> { + let mut pst = con.prepare(include_str!( + "../assets/queries/tags/add_implicit_tag_to_file.sql" + ))?; + for file_id in file_ids { + pst.execute(rusqlite::params![tag_id, file_id, implicit_from_id])?; + } + Ok(()) +} + /// Retrieves all tags on a file, explicit or implied /// /// ## Parameters: @@ -237,20 +269,6 @@ pub fn remove_implicit_tag_from_file( Ok(()) } -/// Updates or inserts an implicit tag on a file, replacing any existing implicit tag from a different ancestor -pub fn upsert_implicit_tag_to_file( - tag_id: u32, - file_id: u32, - implicit_from_id: u32, - con: &Connection, -) -> Result<(), rusqlite::Error> { - // First delete any existing implicit tag - remove_implicit_tag_from_file(tag_id, file_id, con)?; - - // Then insert the new one - add_implicit_tag_to_file(tag_id, file_id, implicit_from_id, con) -} - // ================= folder functions ================= pub fn add_explicit_tag_to_folder( folder_id: u32, @@ -276,6 +294,36 @@ pub fn add_implicit_tag_to_folder( 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 +/// - `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. /// @@ -366,24 +414,6 @@ pub fn remove_implicit_tags_from_folders( Ok(()) } -/// Updates or inserts an implicit tag on a folder, replacing any existing implicit tag from a different ancestor -pub fn upsert_implicit_tag_to_folder( - tag_id: u32, - folder_id: u32, - implicit_from_id: u32, - con: &Connection, -) -> Result<(), rusqlite::Error> { - // First delete any existing implicit tag - remove_implicit_tag_from_folder(tag_id, folder_id, con)?; - - // Then insert the new one - let mut insert_pst = con.prepare(include_str!( - "../assets/queries/tags/add_implicit_tag_to_folder.sql" - ))?; - insert_pst.execute(rusqlite::params![tag_id, folder_id, implicit_from_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. diff --git a/src/tags/service.rs b/src/tags/service.rs index e07c02b..abd9d0e 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -425,20 +425,6 @@ pub fn pass_tags_to_children(folder_id: u32) -> Result<(), TagRelationError> { let con = open_connection(); - // Get all explicit tags on this folder - let explicit_tags = - match tag_repository::get_tags_for_folder(folder_id, TagTypes::Explicit, &con) { - Ok(tags) => tags, - Err(e) => { - log::error!( - "Failed to retrieve tags on folder {folder_id}! Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(TagRelationError::DbError); - } - }; - // 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( &vec![folder_id], @@ -456,7 +442,7 @@ pub fn pass_tags_to_children(folder_id: u32) -> Result<(), TagRelationError> { }; // 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) + 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) => { @@ -478,10 +464,12 @@ pub fn pass_tags_to_children(folder_id: u32) -> Result<(), TagRelationError> { ); 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 all_ancestor_ids = match folder_repository::get_ancestor_folders_with_id(folder_id, &con) { + /* 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(); @@ -492,7 +480,58 @@ pub fn pass_tags_to_children(folder_id: u32) -> Result<(), TagRelationError> { 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); // TODO get all tags for ancestor IDs, get the explicit ones, and make all children inherit them (use insert or ignore) + 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(()) } diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index 6c3ead5..6e23cd6 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -381,7 +381,6 @@ mod implicit_tag_tests { use crate::tags::repository::{ add_implicit_tag_to_file, add_implicit_tag_to_folder, get_all_tags_for_file, get_all_tags_for_folder, remove_implicit_tag_from_file, remove_implicit_tag_from_folder, - upsert_implicit_tag_to_file, upsert_implicit_tag_to_folder, }; use crate::test::*; @@ -418,79 +417,147 @@ mod implicit_tag_tests { } #[test] - fn upsert_implicit_tag_to_folder_replaces_existing() { + fn delete_implicit_tag_from_folder_works() { init_db_folder(); - 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_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 from folder 1 - add_implicit_tag_to_folder(tag_id, 3, 1, &con).unwrap(); - // Upsert to change it to folder 2 - upsert_implicit_tag_to_folder(tag_id, 3, 2, &con).unwrap(); - let tags = get_all_tags_for_folder(3, &con).unwrap(); + // Add implicit tag + add_implicit_tag_to_folder(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(2)); + // Delete the implicit tag + remove_implicit_tag_from_folder(tag_id, 2, &con).unwrap(); + let tags = get_all_tags_for_folder(2, &con).unwrap(); + assert_eq!(tags.len(), 0); con.close().unwrap(); cleanup(); } #[test] - fn upsert_implicit_tag_to_file_replaces_existing() { + fn delete_implicit_tag_from_file_works() { init_db_folder(); - create_folder_db_entry("grandparent", None); // id 1 - create_folder_db_entry("parent", Some(1)); // id 2 - create_file_db_entry("file.txt", Some(2)); + create_folder_db_entry("parent", None); // id 1 + create_file_db_entry("file.txt", Some(1)); let tag_id = create_tag_db_entry("test_tag"); let con = open_connection(); - // Add implicit tag from folder 1 + // Add implicit tag add_implicit_tag_to_file(tag_id, 1, 1, &con).unwrap(); - // Upsert to change it to folder 2 - upsert_implicit_tag_to_file(tag_id, 1, 2, &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(2)); + // Delete the implicit tag + remove_implicit_tag_from_file(tag_id, 1, &con).unwrap(); + let tags = get_all_tags_for_file(1, &con).unwrap(); + assert_eq!(tags.len(), 0); con.close().unwrap(); 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 delete_implicit_tag_from_folder_works() { + 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 - add_implicit_tag_to_folder(tag_id, 2, 1, &con).unwrap(); + 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); - // Delete the implicit tag - remove_implicit_tag_from_folder(tag_id, 2, &con).unwrap(); - let tags = get_all_tags_for_folder(2, &con).unwrap(); - assert_eq!(tags.len(), 0); + 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 delete_implicit_tag_from_file_works() { + fn adds_tags_to_multiple_files() { init_db_folder(); create_folder_db_entry("parent", None); // id 1 - create_file_db_entry("file.txt", Some(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 - add_implicit_tag_to_file(tag_id, 1, 1, &con).unwrap(); + 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); - // Delete the implicit tag - remove_implicit_tag_from_file(tag_id, 1, &con).unwrap(); - let tags = get_all_tags_for_file(1, &con).unwrap(); - assert_eq!(tags.len(), 0); + assert_eq!(tags[0].tag_id, tag_id); + assert_eq!(tags[0].implicit_from_id, Some(1)); con.close().unwrap(); cleanup(); } @@ -499,7 +566,7 @@ mod implicit_tag_tests { mod remove_implicit_tags_tests { use crate::repository::open_connection; use crate::tags::repository::{ - add_implicit_tag_to_file, add_implicit_tag_to_folder, get_all_tags_for_file, + add_implicit_tag_to_files, add_implicit_tag_to_folders, get_all_tags_for_file, get_all_tags_for_folder, remove_implicit_tag_from_files, remove_implicit_tags_from_folders, }; use crate::test::*; @@ -512,8 +579,7 @@ mod remove_implicit_tags_tests { create_folder_db_entry("folder2", Some(1)); // id 3 let tag_id = create_tag_db_entry("test_tag"); let con = open_connection(); - add_implicit_tag_to_folder(tag_id, 2, 1, &con).unwrap(); - add_implicit_tag_to_folder(tag_id, 3, 1, &con).unwrap(); + add_implicit_tag_to_folders(tag_id, &[2, 3], 1, &con).unwrap(); // Remove tags inherited from folder 1 remove_implicit_tags_from_folders(tag_id, 1, &con).unwrap(); let tags2 = get_all_tags_for_folder(2, &con).unwrap(); @@ -532,8 +598,7 @@ mod remove_implicit_tags_tests { create_file_db_entry("file2.txt", Some(1)); let tag_id = create_tag_db_entry("test_tag"); let con = open_connection(); - add_implicit_tag_to_file(tag_id, 1, 1, &con).unwrap(); - add_implicit_tag_to_file(tag_id, 2, 1, &con).unwrap(); + add_implicit_tag_to_files(tag_id, &[1, 2], 1, &con).unwrap(); // Remove tags inherited from folder 1 remove_implicit_tag_from_files(tag_id, 1, &con).unwrap(); let tags1 = get_all_tags_for_file(1, &con).unwrap(); diff --git a/src/tags/tests/service.rs b/src/tags/tests/service.rs index 70b5b38..13d2b3e 100644 --- a/src/tags/tests/service.rs +++ b/src/tags/tests/service.rs @@ -555,10 +555,13 @@ mod get_tags_on_folder_tests { } mod pass_tags_to_children_tests { - - use crate::tags::service::{get_tags_on_file, get_tags_on_folder, pass_tags_to_children}; + + 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_folder, init_db_folder, + cleanup, create_file_db_entry, create_folder_db_entry, create_tag_db_entry, + create_tag_folder, init_db_folder, }; #[test] @@ -633,10 +636,6 @@ mod pass_tags_to_children_tests { create_folder_db_entry("parent", None); // id 1 create_folder_db_entry("child", Some(1)); // id 2 - // Create tag and add explicitly to both - use crate::repository::open_connection; - use crate::tags::repository as tag_repository; - use crate::test::create_tag_db_entry; 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(); @@ -663,10 +662,6 @@ mod pass_tags_to_children_tests { create_folder_db_entry("parent", None); // id 1 create_file_db_entry("file.txt", Some(1)); // id 1 - // Create tag and add explicitly to both - use crate::repository::open_connection; - use crate::tags::repository as tag_repository; - use crate::test::create_tag_db_entry; 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(); @@ -702,9 +697,6 @@ mod pass_tags_to_children_tests { assert_eq!(child_tags.len(), 1); assert_eq!(child_tags[0].implicit_from, Some(1)); - // Remove the tag explicitly from parent - use crate::repository::open_connection; - use crate::tags::repository as tag_repository; let con = open_connection(); tag_repository::remove_explicit_tag_from_folder(1, 1, &con).unwrap(); con.close().unwrap(); @@ -728,16 +720,12 @@ mod pass_tags_to_children_tests { create_folder_db_entry("child", Some(2)); // id 3 // Create tag and add explicitly to both grandparent and parent - use crate::repository::open_connection; - use crate::tags::repository as tag_repository; - use crate::test::create_tag_db_entry; 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(1).unwrap(); + // current state: grandparent+test_tag/parent+test_tag/child pass_tags_to_children(2).unwrap(); // Child should inherit from parent (closer ancestor) @@ -745,8 +733,6 @@ mod pass_tags_to_children_tests { assert_eq!(child_tags.len(), 1); assert_eq!(child_tags[0].implicit_from, Some(2)); - // Remove tag from parent - use crate::tags::service::update_folder_tags; update_folder_tags(2, vec![]).unwrap(); // Child should now inherit from grandparent @@ -767,9 +753,6 @@ mod pass_tags_to_children_tests { create_folder_db_entry("bottom", Some(2)); // id 3 // Create tag once - use crate::repository::open_connection; - use crate::tags::repository as tag_repository; - use crate::test::create_tag_db_entry; let tag_id = create_tag_db_entry("test_tag"); // Add tag to bottom first @@ -802,9 +785,6 @@ mod pass_tags_to_children_tests { create_file_db_entry("file.png", Some(3)); // Create tag once - use crate::repository::open_connection; - use crate::tags::repository as tag_repository; - use crate::test::create_tag_db_entry; let tag_id = create_tag_db_entry("test_tag"); // Add tag to bottom @@ -841,9 +821,6 @@ mod pass_tags_to_children_tests { create_folder_db_entry("bottom", Some(2)); // id 3 // Add tag to all three levels - use crate::repository::open_connection; - use crate::tags::repository as tag_repository; - use crate::test::create_tag_db_entry; 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(); @@ -861,7 +838,6 @@ mod pass_tags_to_children_tests { assert_eq!(bottom_tags[0].implicit_from, None); // Remove tag from top - bottom should still have it explicitly - use crate::tags::service::update_folder_tags; update_folder_tags(1, vec![]).unwrap(); let bottom_tags = get_tags_on_folder(3).unwrap(); diff --git a/src/test/folder_handler_tests.rs b/src/test/folder_handler_tests.rs index a73143b..6548d52 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(vec![]); assert_eq!( root_files[0], FileRecord { From 6c926c613073a26ef4a1028432e822f026933e5c Mon Sep 17 00:00:00 2001 From: Ploiu <43047560+ploiu@users.noreply.github.com> Date: Sun, 23 Nov 2025 10:03:23 -0500 Subject: [PATCH 30/61] Update src/tags/repository.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/tags/repository.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tags/repository.rs b/src/tags/repository.rs index 01c4cef..da7818f 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -301,7 +301,7 @@ pub fn add_implicit_tag_to_folder( /// ## 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 +/// - `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: From 2b72825516bdf3a23f4b15bb11e0f8c5c25c0f45 Mon Sep 17 00:00:00 2001 From: Ploiu <43047560+ploiu@users.noreply.github.com> Date: Sun, 23 Nov 2025 10:05:25 -0500 Subject: [PATCH 31/61] Update src/tags/repository.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/tags/repository.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tags/repository.rs b/src/tags/repository.rs index da7818f..22e58b1 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -145,7 +145,7 @@ pub fn add_implicit_tag_to_files( /// - `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 not tags, an empty vec is returned +/// 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, From 1ae2e4d644cf7707e0bda84a1811a2d8ab4ea8c4 Mon Sep 17 00:00:00 2001 From: Ploiu <43047560+ploiu@users.noreply.github.com> Date: Sun, 23 Nov 2025 10:06:42 -0500 Subject: [PATCH 32/61] Update src/tags/repository.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/tags/repository.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tags/repository.rs b/src/tags/repository.rs index 22e58b1..4033a70 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -196,7 +196,7 @@ pub fn get_tags_for_file( /// - `Err(rusqlite::Error)` if there was a database error /// /// --- -/// See also [get_all_tags_on_file] +/// See also [`get_all_tags_for_file`] /// pub fn get_all_tags_for_files( file_ids: Vec, From 738a8d472940a5fdcc99b69c07d32a0696b61ab0 Mon Sep 17 00:00:00 2001 From: Ploiu <43047560+ploiu@users.noreply.github.com> Date: Sun, 23 Nov 2025 10:06:52 -0500 Subject: [PATCH 33/61] Update src/tags/service.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/tags/service.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tags/service.rs b/src/tags/service.rs index abd9d0e..4768267 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -484,7 +484,6 @@ pub fn pass_tags_to_children(folder_id: u32) -> Result<(), TagRelationError> { 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); - // TODO get all tags for ancestor IDs, get the explicit ones, and make all children inherit them (use insert or ignore) for ancestor in all_ancestor_ids { let ancestor_tags = match repository::get_tags_for_folder( ancestor, From 6a1c2963d43a9c47a80a37cb221204cdbc9963d6 Mon Sep 17 00:00:00 2001 From: Ploiu <43047560+ploiu@users.noreply.github.com> Date: Sun, 23 Nov 2025 10:07:04 -0500 Subject: [PATCH 34/61] Update src/tags/repository.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/tags/repository.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tags/repository.rs b/src/tags/repository.rs index 4033a70..ce9594b 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -112,7 +112,7 @@ pub fn add_implicit_tag_to_file( /// ## 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 +/// - `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: From e57574204c89521b75e057cf822908e9251e303f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 15:13:15 +0000 Subject: [PATCH 35/61] Change get_all_child_folder_ids to accept &[u32] instead of IntoIterator - Simplified function signature to accept slice directly - Removed unnecessary vec allocation in tags/service.rs - Changed clone().into_iter() to iter().copied() for better performance Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- src/repository/folder_repository.rs | 6 +++--- src/tags/service.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/repository/folder_repository.rs b/src/repository/folder_repository.rs index 2c67fee..a349e86 100644 --- a/src/repository/folder_repository.rs +++ b/src/repository/folder_repository.rs @@ -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() diff --git a/src/tags/service.rs b/src/tags/service.rs index 4768267..1a89a17 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -427,7 +427,7 @@ pub fn pass_tags_to_children(folder_id: u32) -> Result<(), TagRelationError> { // 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( - &vec![folder_id], + &[folder_id], &con, ) { Ok(folders) => folders, From f4fd325f1fb0a12665623f9cfa072d898673a506 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 00:36:01 +0000 Subject: [PATCH 36/61] Initial plan From ab7e39934dceec45bf3fe4b8113a5679195ef11f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 00:43:46 +0000 Subject: [PATCH 37/61] Implement folder move implicit tag recalculation - Detect parent_id changes in update_folder - Retrieve original ancestors before database update - Remove implicit tags from descendants that came from old ancestors - Add test: moving folder to another folder recalculates descendant implicit tags - Add test: moving folder to root removes all descendant implicit tags Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- src/service/folder_service.rs | 195 ++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) diff --git a/src/service/folder_service.rs b/src/service/folder_service.rs index 669f72a..3d60d5f 100644 --- a/src/service/folder_service.rs +++ b/src/service/folder_service.rs @@ -25,6 +25,7 @@ use crate::service::file_service; use crate::service::file_service::{check_root_dir, file_dir}; use crate::tags::repository as tag_repository; use crate::tags::service as tag_service; +use crate::tags::TagTypes; use crate::{model, repository}; pub fn get_folder(id: Option) -> Result { @@ -125,6 +126,29 @@ 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( @@ -145,6 +169,83 @@ pub fn update_folder(folder: &UpdateFolderRequest) -> Result 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 folder and its descendants + let mut all_folder_ids = descendant_folders.clone(); + all_folder_ids.push(folder.id); + match folder_repository::get_child_files(&all_folder_ids, &con) { + Ok(_files) => { /* retrieved successfully, we just needed to check */ } + Err(e) => { + log::error!( + "Failed to retrieve descendant files for folder {}. Error is {e:?}\n{}", + folder.id, + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(UpdateFolderError::DbFailure); + } + }; + + // For each old ancestor, get its tags and remove them from descendants + for ancestor_id in original_ancestors { + let ancestor_tags = match tag_repository::get_tags_for_folder(ancestor_id, TagTypes::Explicit, &con) { + Ok(tags) => tags, + Err(e) => { + log::error!( + "Failed to retrieve tags for ancestor folder {}. Error is {e:?}\n{}", + ancestor_id, + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(UpdateFolderError::TagError); + } + }; + + // Remove implicit tags from descendant files + for tag in &ancestor_tags { + if let Err(e) = tag_repository::remove_implicit_tag_from_files(tag.tag_id, ancestor_id, &con) { + log::error!( + "Failed to remove implicit tag from files. Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(UpdateFolderError::TagError); + } + } + + // Remove implicit tags from descendant folders + for tag in &ancestor_tags { + if let Err(e) = tag_repository::remove_implicit_tags_from_folders(tag.tag_id, ancestor_id, &con) { + log::error!( + "Failed to remove implicit tag from folders. Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(UpdateFolderError::TagError); + } + } + } + + con.close().unwrap(); + } + match tag_service::update_folder_tags(updated_folder.id.unwrap(), folder.tags.clone()) { Ok(()) => { /*no op*/ } Err(_) => { @@ -839,6 +940,100 @@ mod update_folder_tests { assert_eq!(child.tags.len(), 0); cleanup(); } + + #[test] + fn moving_a_folder_to_another_folder_recalculates_descendant_implicit_tags() { + 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 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 = get_folder(Some(3)).unwrap(); + assert_eq!(child.tags.len(), 1); + assert_eq!(child.tags[0].title, "grandparent_tag"); + assert_eq!(child.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); + 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 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"); + + // 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 = get_folder(Some(3)).unwrap(); + assert_eq!(child.tags.len(), 1); + assert_eq!(child.tags[0].title, "grandparent_tag"); + assert_eq!(child.tags[0].implicit_from, Some(1)); + + // Now move parent folder to root - should remove grandparent's implicit tag from descendants + update_folder(&UpdateFolderRequest { + id: 2, + name: "parent".to_string(), + parent_id: None, // moving to root + 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); + cleanup(); + } } #[cfg(test)] From a75399d2e978fc5e174cb05a0b91da923faba757 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 01:14:18 +0000 Subject: [PATCH 38/61] Refactor: Extract folder move tag handling to separate function - Move tag removal logic to new `handle_folder_move_for_tags` function - Remove unnecessary file retrieval check that wasn't being used - Repository functions already handle finding descendants automatically Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- src/service/folder_service.rs | 135 ++++++++++++++++------------------ 1 file changed, 62 insertions(+), 73 deletions(-) diff --git a/src/service/folder_service.rs b/src/service/folder_service.rs index 3d60d5f..a8d56be 100644 --- a/src/service/folder_service.rs +++ b/src/service/folder_service.rs @@ -169,81 +169,10 @@ pub fn update_folder(folder: &UpdateFolderRequest) -> Result 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 folder and its descendants - let mut all_folder_ids = descendant_folders.clone(); - all_folder_ids.push(folder.id); - match folder_repository::get_child_files(&all_folder_ids, &con) { - Ok(_files) => { /* retrieved successfully, we just needed to check */ } - Err(e) => { - log::error!( - "Failed to retrieve descendant files for folder {}. Error is {e:?}\n{}", - folder.id, - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(UpdateFolderError::DbFailure); - } - }; - - // For each old ancestor, get its tags and remove them from descendants - for ancestor_id in original_ancestors { - let ancestor_tags = match tag_repository::get_tags_for_folder(ancestor_id, TagTypes::Explicit, &con) { - Ok(tags) => tags, - Err(e) => { - log::error!( - "Failed to retrieve tags for ancestor folder {}. Error is {e:?}\n{}", - ancestor_id, - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(UpdateFolderError::TagError); - } - }; - - // Remove implicit tags from descendant files - for tag in &ancestor_tags { - if let Err(e) = tag_repository::remove_implicit_tag_from_files(tag.tag_id, ancestor_id, &con) { - log::error!( - "Failed to remove implicit tag from files. Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(UpdateFolderError::TagError); - } - } - - // Remove implicit tags from descendant folders - for tag in &ancestor_tags { - if let Err(e) = tag_repository::remove_implicit_tags_from_folders(tag.tag_id, ancestor_id, &con) { - log::error!( - "Failed to remove implicit tag from folders. Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(UpdateFolderError::TagError); - } - } - } - - con.close().unwrap(); + handle_folder_move_for_tags(folder.id, original_ancestors)?; } match tag_service::update_folder_tags(updated_folder.id.unwrap(), folder.tags.clone()) { @@ -604,6 +533,66 @@ 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(); + + // For each old ancestor, get its tags and remove them from descendants + for ancestor_id in original_ancestors { + let ancestor_tags = match tag_repository::get_tags_for_folder(ancestor_id, TagTypes::Explicit, &con) { + Ok(tags) => tags, + Err(e) => { + log::error!( + "Failed to retrieve tags for ancestor folder {}. Error is {e:?}\n{}", + ancestor_id, + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(UpdateFolderError::TagError); + } + }; + + // Remove implicit tags from descendant files and folders + // The repository functions handle finding all descendants automatically + for tag in &ancestor_tags { + if let Err(e) = tag_repository::remove_implicit_tag_from_files(tag.tag_id, ancestor_id, &con) { + log::error!( + "Failed to remove implicit tag from files. Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(UpdateFolderError::TagError); + } + + if let Err(e) = tag_repository::remove_implicit_tags_from_folders(tag.tag_id, ancestor_id, &con) { + log::error!( + "Failed to remove implicit tag from folders. Error is {e:?}\n{}", + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(UpdateFolderError::TagError); + } + } + } + + con.close().unwrap(); + Ok(()) +} + /// returns the top-level files for the passed folder fn get_files_for_folder( id: Option, From 9dbd14feab74c6f0404b38665cc20bd636fc3fc6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 01:29:44 +0000 Subject: [PATCH 39/61] Add tests for unaffected folders/files and fix tag removal logic - Add test: moving_folder_does_not_remove_tags_from_unaffected_folders - Add test: moving_folder_does_not_remove_explicit_tags_from_descendants - Fix handle_folder_move_for_tags to only remove tags from moved folder's descendants - Previously removed tags from ALL items with that implicit_from_id (affecting siblings) - Now specifically targets only descendants of the moved folder Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- src/service/folder_service.rs | 224 +++++++++++++++++++++++++++++++--- 1 file changed, 207 insertions(+), 17 deletions(-) diff --git a/src/service/folder_service.rs b/src/service/folder_service.rs index a8d56be..16afaf4 100644 --- a/src/service/folder_service.rs +++ b/src/service/folder_service.rs @@ -551,7 +551,37 @@ fn handle_folder_move_for_tags( ) -> Result<(), UpdateFolderError> { let con = repository::open_connection(); - // For each old ancestor, get its tags and remove them from descendants + // Get all descendant folders of the moved folder + 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) => { + log::error!( + "Failed to retrieve descendant files for folder {}. Error is {e:?}\n{}", + folder_id, + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(UpdateFolderError::DbFailure); + } + }; + + // For each old ancestor, get its tags and remove them only from the descendants of this folder for ancestor_id in original_ancestors { let ancestor_tags = match tag_repository::get_tags_for_folder(ancestor_id, TagTypes::Explicit, &con) { Ok(tags) => tags, @@ -566,25 +596,33 @@ fn handle_folder_move_for_tags( } }; - // Remove implicit tags from descendant files and folders - // The repository functions handle finding all descendants automatically + // Remove implicit tags from descendant files (only those that are descendants of the moved folder) for tag in &ancestor_tags { - if let Err(e) = tag_repository::remove_implicit_tag_from_files(tag.tag_id, ancestor_id, &con) { - log::error!( - "Failed to remove implicit tag from files. Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(UpdateFolderError::TagError); + for file_id in &descendant_files { + if let Err(e) = tag_repository::remove_implicit_tag_from_file(tag.tag_id, *file_id, &con) { + log::error!( + "Failed to remove implicit tag from file {}. Error is {e:?}\n{}", + file_id, + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(UpdateFolderError::TagError); + } } + } - if let Err(e) = tag_repository::remove_implicit_tags_from_folders(tag.tag_id, ancestor_id, &con) { - log::error!( - "Failed to remove implicit tag from folders. Error is {e:?}\n{}", - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(UpdateFolderError::TagError); + // Remove implicit tags from descendant folders (only those that are descendants of the moved folder) + for tag in &ancestor_tags { + for descendant_folder_id in &descendant_folders { + if let Err(e) = tag_repository::remove_implicit_tag_from_folder(tag.tag_id, *descendant_folder_id, &con) { + log::error!( + "Failed to remove implicit tag from folder {}. Error is {e:?}\n{}", + descendant_folder_id, + Backtrace::force_capture() + ); + con.close().unwrap(); + return Err(UpdateFolderError::TagError); + } } } } @@ -1023,6 +1061,158 @@ mod update_folder_tests { assert_eq!(child.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"); + + use crate::test::create_file_db_entry; + 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 + use crate::tags::service::update_file_tags; + update_file_tags( + 1, + vec![TaggedItemApi { + tag_id: None, + title: "file_explicit_tag".to_string(), + implicit_from: None, + }], + ) + .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 + use crate::tags::service::get_tags_on_file; + 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(); + } } #[cfg(test)] From b5780fe2f55625949d267fa3853b33fa5b543bb4 Mon Sep 17 00:00:00 2001 From: ploiu Date: Mon, 24 Nov 2025 16:37:21 +0000 Subject: [PATCH 40/61] some cleanup --- .../tags/batch_remove_implicit_tags.sql | 8 + src/service/folder_service.rs | 228 +++++++----------- src/tags/repository.rs | 47 ++-- src/tags/tests/repository.rs | 21 +- 4 files changed, 127 insertions(+), 177 deletions(-) create mode 100644 src/assets/queries/tags/batch_remove_implicit_tags.sql diff --git a/src/assets/queries/tags/batch_remove_implicit_tags.sql b/src/assets/queries/tags/batch_remove_implicit_tags.sql new file mode 100644 index 0000000..914b9ad --- /dev/null +++ b/src/assets/queries/tags/batch_remove_implicit_tags.sql @@ -0,0 +1,8 @@ +delete from + TaggedItems +where + ( + fileId in (:fileIds) + or folderId in (:folderIds) + ) + and implicitFromId in (:implicitFromIds) \ No newline at end of file diff --git a/src/service/folder_service.rs b/src/service/folder_service.rs index 16afaf4..6fe366f 100644 --- a/src/service/folder_service.rs +++ b/src/service/folder_service.rs @@ -23,9 +23,9 @@ use crate::previews; use crate::repository::{folder_repository, open_connection}; use crate::service::file_service; use crate::service::file_service::{check_root_dir, file_dir}; +use crate::tags::TagTypes; use crate::tags::repository as tag_repository; use crate::tags::service as tag_service; -use crate::tags::TagTypes; use crate::{model, repository}; pub fn get_folder(id: Option) -> Result { @@ -113,6 +113,7 @@ pub fn update_folder(folder: &UpdateFolderRequest) -> Result f, Err(GetFolderError::NotFound) => return Err(UpdateFolderError::NotFound), @@ -123,10 +124,11 @@ pub fn update_folder(folder: &UpdateFolderRequest) -> Result Result Result { /*no op*/ } - Err(_) => { - return Err(UpdateFolderError::TagError); - } - }; + + tag_service::update_folder_tags(updated_folder.id.unwrap(), folder.tags.clone()) + .map_err(|_| UpdateFolderError::TagError)?; Ok(FolderResponse { id: updated_folder.id.unwrap(), folders: Vec::new(), @@ -551,7 +549,7 @@ fn handle_folder_move_for_tags( ) -> Result<(), UpdateFolderError> { let con = repository::open_connection(); - // Get all descendant folders of the moved folder + // 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) => { @@ -568,63 +566,33 @@ fn handle_folder_move_for_tags( // 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) { + 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() ); - con.close().unwrap(); return Err(UpdateFolderError::DbFailure); } }; - // For each old ancestor, get its tags and remove them only from the descendants of this folder - for ancestor_id in original_ancestors { - let ancestor_tags = match tag_repository::get_tags_for_folder(ancestor_id, TagTypes::Explicit, &con) { - Ok(tags) => tags, - Err(e) => { - log::error!( - "Failed to retrieve tags for ancestor folder {}. Error is {e:?}\n{}", - ancestor_id, - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(UpdateFolderError::TagError); - } - }; - - // Remove implicit tags from descendant files (only those that are descendants of the moved folder) - for tag in &ancestor_tags { - for file_id in &descendant_files { - if let Err(e) = tag_repository::remove_implicit_tag_from_file(tag.tag_id, *file_id, &con) { - log::error!( - "Failed to remove implicit tag from file {}. Error is {e:?}\n{}", - file_id, - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(UpdateFolderError::TagError); - } - } - } - - // Remove implicit tags from descendant folders (only those that are descendants of the moved folder) - for tag in &ancestor_tags { - for descendant_folder_id in &descendant_folders { - if let Err(e) = tag_repository::remove_implicit_tag_from_folder(tag.tag_id, *descendant_folder_id, &con) { - log::error!( - "Failed to remove implicit tag from folder {}. Error is {e:?}\n{}", - descendant_folder_id, - Backtrace::force_capture() - ); - con.close().unwrap(); - return Err(UpdateFolderError::TagError); - } - } - } + // For each old ancestor, remove all implicit tags from that ancestor on the descendants of the folder being moved + if let Err(e) = tag_repository::batch_remove_implicit_tags( + &descendant_files, + &descendant_folders, + &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(); @@ -780,9 +748,11 @@ mod update_folder_tests { use crate::model::request::folder_requests::UpdateFolderRequest; 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::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, init_db_folder, }; #[test] @@ -969,20 +939,19 @@ mod update_folder_tests { } #[test] - fn moving_a_folder_to_another_folder_recalculates_descendant_implicit_tags() { + fn moving_a_folder_to_root_removes_all_descendant_implicit_tags_from_original_ancestors() { init_db_folder(); - // Create folder structure: grandparent, parent (child of grandparent), child (child of parent) + // 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_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, @@ -995,70 +964,31 @@ mod update_folder_tests { }], }) .unwrap(); - + // Verify child has implicit tag from grandparent - let child = get_folder(Some(3)).unwrap(); - assert_eq!(child.tags.len(), 1); - assert_eq!(child.tags[0].title, "grandparent_tag"); - assert_eq!(child.tags[0].implicit_from, Some(1)); - + 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 + 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); - 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 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"); - - // 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 = get_folder(Some(3)).unwrap(); - assert_eq!(child.tags.len(), 1); - assert_eq!(child.tags[0].title, "grandparent_tag"); - assert_eq!(child.tags[0].implicit_from, Some(1)); - - // Now move parent folder to root - should remove grandparent's implicit tag from descendants - update_folder(&UpdateFolderRequest { - id: 2, - name: "parent".to_string(), - parent_id: None, // moving to root - 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(); } @@ -1078,7 +1008,7 @@ mod update_folder_tests { 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, @@ -1091,31 +1021,35 @@ mod update_folder_tests { }], }) .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 + 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.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"); @@ -1132,10 +1066,10 @@ mod update_folder_tests { create_folder_disk("grandparent/parent"); create_folder_db_entry("child", Some(2)); create_folder_disk("grandparent/parent/child"); - + use crate::test::create_file_db_entry; 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, @@ -1148,7 +1082,7 @@ mod update_folder_tests { }], }) .unwrap(); - + // Add an explicit tag to child folder update_folder(&UpdateFolderRequest { id: 3, @@ -1161,7 +1095,7 @@ mod update_folder_tests { }], }) .unwrap(); - + // Add an explicit tag to the file use crate::tags::service::update_file_tags; update_file_tags( @@ -1173,46 +1107,60 @@ mod update_folder_tests { }], ) .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"); - + 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 use crate::tags::service::get_tags_on_file; 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 + 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.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()); + 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"); + assert!( + !has_grandparent_tag, + "File should not have grandparent tag after move" + ); cleanup(); } + } #[cfg(test)] diff --git a/src/tags/repository.rs b/src/tags/repository.rs index ce9594b..3cb2b33 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -1,5 +1,6 @@ use std::{backtrace::Backtrace, collections::HashMap}; +use itertools::Itertools; use rusqlite::Connection; use crate::tags::TagTypes; @@ -239,23 +240,6 @@ pub fn remove_explicit_tag_from_file( Ok(()) } -/// Removes a single implied tag from all files that the passed `implicit_from_id` implicates the tag on -/// -/// ## Parameters: -/// - `tag_id`: the tag to remove from those files -/// - `implicit_from_id`: the folder that was implicating the tag on the files -/// - `con`: a connection to the database. Must be closed by the caller -pub fn remove_implicit_tag_from_files( - tag_id: u32, - implicit_from_id: u32, - con: &Connection, -) -> Result<(), rusqlite::Error> { - let query = include_str!("../assets/queries/tags/remove_implicit_tag_from_files.sql"); - let mut pst = con.prepare(&query)?; - pst.execute(rusqlite::params![tag_id, implicit_from_id])?; - Ok(()) -} - /// Deletes an implicit tag from a file if it exists pub fn remove_implicit_tag_from_file( tag_id: u32, @@ -434,6 +418,35 @@ pub fn remove_stale_implicit_tags_from_descendants( 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 +pub fn batch_remove_implicit_tags( + file_ids: &[u32], + folder_ids: &[u32], + implicit_from_ids: &[u32], + con: &Connection, +) -> Result<(), rusqlite::Error> { + // rusqlite does not allow us to pass an array of values as a param, so we must create them manually + let sql_file_ids: String = file_ids.iter().map(|&it| it.to_string()).join(","); + let sql_folder_ids: String = folder_ids.iter().map(|&it| it.to_string()).join(","); + let sql_implicit_from_ids: String = + implicit_from_ids.iter().map(|&it| it.to_string()).join(","); + + let formatted_sql = include_str!("../assets/queries/tags/batch_remove_implicit_tags.sql") + .replace(":fileIds", &sql_file_ids) + .replace(":folderIds", &sql_folder_ids) + .replace(":implicitFromIds", &sql_implicit_from_ids); + con.execute_batch(&formatted_sql) +} + // ================= misc ================= /// 1. id /// 2. fileId diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index 6e23cd6..0da1c72 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -567,7 +567,7 @@ mod remove_implicit_tags_tests { use crate::repository::open_connection; use crate::tags::repository::{ add_implicit_tag_to_files, add_implicit_tag_to_folders, get_all_tags_for_file, - get_all_tags_for_folder, remove_implicit_tag_from_files, remove_implicit_tags_from_folders, + get_all_tags_for_folder, remove_implicit_tags_from_folders, }; use crate::test::*; @@ -589,23 +589,4 @@ mod remove_implicit_tags_tests { con.close().unwrap(); cleanup(); } - - #[test] - fn remove_implicit_tags_from_files_works() { - init_db_folder(); - create_folder_db_entry("parent", None); // id 1 - create_file_db_entry("file1.txt", Some(1)); - create_file_db_entry("file2.txt", Some(1)); - 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(); - // Remove tags inherited from folder 1 - remove_implicit_tag_from_files(tag_id, 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(); - } } From eeccaf9ebcbe68cc886ca21595e7991f957b42eb Mon Sep 17 00:00:00 2001 From: ploiu Date: Mon, 24 Nov 2025 16:47:07 +0000 Subject: [PATCH 41/61] add more tests --- .github/copilot-instructions.md | 3 + src/service/folder_service.rs | 278 +++++++++++++++++++++++++++++++- src/tags/service.rs | 5 +- src/tags/tests/repository.rs | 2 +- 4 files changed, 276 insertions(+), 12 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bfe359f..8bbd2a8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -149,5 +149,8 @@ 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/src/service/folder_service.rs b/src/service/folder_service.rs index 6fe366f..f89dec5 100644 --- a/src/service/folder_service.rs +++ b/src/service/folder_service.rs @@ -23,7 +23,6 @@ use crate::previews; use crate::repository::{folder_repository, open_connection}; use crate::service::file_service; use crate::service::file_service::{check_root_dir, file_dir}; -use crate::tags::TagTypes; use crate::tags::repository as tag_repository; use crate::tags::service as tag_service; use crate::{model, repository}; @@ -750,6 +749,7 @@ mod update_folder_tests { 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_file_db_entry, create_folder_db_entry, create_folder_disk, create_tag_folder, init_db_folder, @@ -870,7 +870,6 @@ mod update_folder_tests { create_folder_db_entry("parent", None); create_folder_disk("parent"); - use crate::test::create_file_db_entry; create_file_db_entry("file.txt", Some(1)); update_folder(&UpdateFolderRequest { @@ -886,7 +885,6 @@ mod update_folder_tests { .unwrap(); // Check file has implicit tag - use crate::tags::service::get_tags_on_file; let file_tags = get_tags_on_file(1).unwrap(); let expected = TaggedItemApi { tag_id: Some(1), @@ -1067,7 +1065,6 @@ mod update_folder_tests { create_folder_db_entry("child", Some(2)); create_folder_disk("grandparent/parent/child"); - use crate::test::create_file_db_entry; create_file_db_entry("file.txt", Some(3)); // Add a tag to grandparent - should be implicated on parent and child @@ -1097,7 +1094,6 @@ mod update_folder_tests { .unwrap(); // Add an explicit tag to the file - use crate::tags::service::update_file_tags; update_file_tags( 1, vec![TaggedItemApi { @@ -1121,7 +1117,6 @@ mod update_folder_tests { ); // Verify file has implicit and explicit tags before the move - use crate::tags::service::get_tags_on_file; 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"); @@ -1160,7 +1155,276 @@ mod update_folder_tests { ); cleanup(); } - + + #[test] + fn update_folder_implies_tags_to_descendant_files_in_nested_structure() { + 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"); + + 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(); + } } #[cfg(test)] diff --git a/src/tags/service.rs b/src/tags/service.rs index 1a89a17..af261a2 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -426,10 +426,7 @@ pub fn pass_tags_to_children(folder_id: u32) -> Result<(), TagRelationError> { 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, - ) { + let mut all_folder_ids = match folder_repository::get_all_child_folder_ids(&[folder_id], &con) { Ok(folders) => folders, Err(e) => { log::error!( diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index 0da1c72..b8d4bc5 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -566,7 +566,7 @@ mod add_implicit_tag_to_files_tests { mod remove_implicit_tags_tests { use crate::repository::open_connection; use crate::tags::repository::{ - add_implicit_tag_to_files, add_implicit_tag_to_folders, get_all_tags_for_file, + add_implicit_tag_to_folders, get_all_tags_for_folder, remove_implicit_tags_from_folders, }; use crate::test::*; From 5c0da8e736b4f0554bb3eb12e6a1cd5fb4e14097 Mon Sep 17 00:00:00 2001 From: ploiu Date: Mon, 24 Nov 2025 16:55:12 +0000 Subject: [PATCH 42/61] some cleanup --- .../tags/remove_implicit_tag_from_files.sql | 6 -- .../tags/remove_implicit_tag_from_folder.sql | 4 - .../remove_implicit_tags_from_folders.sql | 6 -- src/tags/repository.rs | 68 --------------- src/tags/tests/repository.rs | 86 +------------------ 5 files changed, 2 insertions(+), 168 deletions(-) delete mode 100644 src/assets/queries/tags/remove_implicit_tag_from_files.sql delete mode 100644 src/assets/queries/tags/remove_implicit_tag_from_folder.sql delete mode 100644 src/assets/queries/tags/remove_implicit_tags_from_folders.sql diff --git a/src/assets/queries/tags/remove_implicit_tag_from_files.sql b/src/assets/queries/tags/remove_implicit_tag_from_files.sql deleted file mode 100644 index 10d451b..0000000 --- a/src/assets/queries/tags/remove_implicit_tag_from_files.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Remove implicit tags from files where the tag is inherited from a specific folder -delete from - TaggedItems -where - tagId = ?1 - and implicitFromId = ?2 \ No newline at end of file diff --git a/src/assets/queries/tags/remove_implicit_tag_from_folder.sql b/src/assets/queries/tags/remove_implicit_tag_from_folder.sql deleted file mode 100644 index 6e216b5..0000000 --- a/src/assets/queries/tags/remove_implicit_tag_from_folder.sql +++ /dev/null @@ -1,4 +0,0 @@ -delete from TaggedItems -where folderId = ?1 - and tagId = ?2 - and implicitFromId is not null diff --git a/src/assets/queries/tags/remove_implicit_tags_from_folders.sql b/src/assets/queries/tags/remove_implicit_tags_from_folders.sql deleted file mode 100644 index b237a54..0000000 --- a/src/assets/queries/tags/remove_implicit_tags_from_folders.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Remove implicit tags from folders where the tag is inherited from a specific folder -delete from - TaggedItems -where - tagId = ?1 - and implicitFromId = ?2 \ No newline at end of file diff --git a/src/tags/repository.rs b/src/tags/repository.rs index 3cb2b33..bf2d875 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -82,30 +82,6 @@ pub fn add_explicit_tag_to_file( Ok(()) } -/// Adds an implicit tag to a file -/// -/// This function will _only_ add a tag to a file if it doesn't already have that tag (explicit or implicit) -/// -/// Parameters: -/// - `tag_id`: the id of the tag to add -/// - `file_id`: the id of the file to add the tag to -/// - `implicit_from_id`: the id of the folder that implicates the tag on the file -/// -/// ## Returns: -/// will return a rusqlite error if a database interaction fails -pub fn add_implicit_tag_to_file( - tag_id: u32, - file_id: u32, - implicit_from_id: u32, - con: &Connection, -) -> Result<(), rusqlite::Error> { - let mut pst = con.prepare(include_str!( - "../assets/queries/tags/add_implicit_tag_to_file.sql" - ))?; - pst.execute(rusqlite::params![tag_id, file_id, implicit_from_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) @@ -264,20 +240,6 @@ pub fn add_explicit_tag_to_folder( Ok(()) } -/// Adds an implicit tag to a folder (won't add if already exists) -pub fn add_implicit_tag_to_folder( - tag_id: u32, - folder_id: 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" - ))?; - pst.execute(rusqlite::params![tag_id, folder_id, implicit_from_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) @@ -368,36 +330,6 @@ pub fn remove_explicit_tag_from_folder( Ok(()) } -/// Deletes an implicit tag from a folder if it exists -pub fn remove_implicit_tag_from_folder( - tag_id: u32, - folder_id: u32, - con: &Connection, -) -> Result<(), rusqlite::Error> { - let mut pst = con.prepare(include_str!( - "../assets/queries/tags/remove_implicit_tag_from_folder.sql" - ))?; - pst.execute(rusqlite::params![folder_id, tag_id])?; - Ok(()) -} - -/// Removes a single implicit tag from all folders that the passed `implicit_from_id` implicates the tag on -/// -/// ## Parameters: -/// - `tag_id`: the tag to remove -/// - `implicit_from_id`: the folder that implicates the tag that should be removed -/// - `con`: a connection to the database. Must be closed by the caller -pub fn remove_implicit_tags_from_folders( - tag_id: u32, - implicit_from_id: u32, - con: &Connection, -) -> Result<(), rusqlite::Error> { - let query = include_str!("../assets/queries/tags/remove_implicit_tags_from_folders.sql"); - let mut pst = con.prepare(&query)?; - pst.execute(rusqlite::params![tag_id, implicit_from_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. diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index b8d4bc5..31e48a8 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -378,63 +378,9 @@ mod get_tags_on_files_tests { mod implicit_tag_tests { use crate::repository::open_connection; - use crate::tags::repository::{ - add_implicit_tag_to_file, add_implicit_tag_to_folder, get_all_tags_for_file, - get_all_tags_for_folder, remove_implicit_tag_from_file, remove_implicit_tag_from_folder, - }; + use crate::tags::repository::{get_all_tags_for_file, remove_implicit_tag_from_file}; use crate::test::*; - #[test] - fn add_implicit_tag_to_folder_works() { - 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_folder(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(); - } - - #[test] - fn add_implicit_tag_to_file_works() { - init_db_folder(); - create_folder_db_entry("parent", None); // id 1 - create_file_db_entry("file.txt", Some(1)); - let tag_id = create_tag_db_entry("test_tag"); - let con = open_connection(); - add_implicit_tag_to_file(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(); - } - - #[test] - fn delete_implicit_tag_from_folder_works() { - 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 - add_implicit_tag_to_folder(tag_id, 2, 1, &con).unwrap(); - let tags = get_all_tags_for_folder(2, &con).unwrap(); - assert_eq!(tags.len(), 1); - // Delete the implicit tag - remove_implicit_tag_from_folder(tag_id, 2, &con).unwrap(); - let tags = get_all_tags_for_folder(2, &con).unwrap(); - assert_eq!(tags.len(), 0); - con.close().unwrap(); - cleanup(); - } - #[test] fn delete_implicit_tag_from_file_works() { init_db_folder(); @@ -443,7 +389,7 @@ mod implicit_tag_tests { let tag_id = create_tag_db_entry("test_tag"); let con = open_connection(); // Add implicit tag - add_implicit_tag_to_file(tag_id, 1, 1, &con).unwrap(); + crate::test::imply_tag_on_file(tag_id, 1, 1); let tags = get_all_tags_for_file(1, &con).unwrap(); assert_eq!(tags.len(), 1); // Delete the implicit tag @@ -562,31 +508,3 @@ mod add_implicit_tag_to_files_tests { cleanup(); } } - -mod remove_implicit_tags_tests { - use crate::repository::open_connection; - use crate::tags::repository::{ - add_implicit_tag_to_folders, - get_all_tags_for_folder, remove_implicit_tags_from_folders, - }; - use crate::test::*; - - #[test] - fn remove_implicit_tags_from_folders_works() { - init_db_folder(); - create_folder_db_entry("parent", None); // id 1 - create_folder_db_entry("folder1", Some(1)); // id 2 - create_folder_db_entry("folder2", 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(); - // Remove tags inherited from folder 1 - remove_implicit_tags_from_folders(tag_id, 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(); - } -} From 59116a7ec969b87d9749246fd5a0a2cfa58affb6 Mon Sep 17 00:00:00 2001 From: ploiu Date: Mon, 24 Nov 2025 17:59:33 +0000 Subject: [PATCH 43/61] rewrite batch update function --- .../tags/batch_remove_implicit_tags.sql | 8 - src/tags/repository.rs | 55 +++++-- src/tags/tests/repository.rs | 148 ++++++++++++++++++ src/test/mod.rs | 13 ++ 4 files changed, 205 insertions(+), 19 deletions(-) delete mode 100644 src/assets/queries/tags/batch_remove_implicit_tags.sql diff --git a/src/assets/queries/tags/batch_remove_implicit_tags.sql b/src/assets/queries/tags/batch_remove_implicit_tags.sql deleted file mode 100644 index 914b9ad..0000000 --- a/src/assets/queries/tags/batch_remove_implicit_tags.sql +++ /dev/null @@ -1,8 +0,0 @@ -delete from - TaggedItems -where - ( - fileId in (:fileIds) - or folderId in (:folderIds) - ) - and implicitFromId in (:implicitFromIds) \ No newline at end of file diff --git a/src/tags/repository.rs b/src/tags/repository.rs index bf2d875..9bb7c52 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -360,23 +360,56 @@ pub fn remove_stale_implicit_tags_from_descendants( /// - `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> { - // rusqlite does not allow us to pass an array of values as a param, so we must create them manually - let sql_file_ids: String = file_ids.iter().map(|&it| it.to_string()).join(","); - let sql_folder_ids: String = folder_ids.iter().map(|&it| it.to_string()).join(","); - let sql_implicit_from_ids: String = - implicit_from_ids.iter().map(|&it| it.to_string()).join(","); - - let formatted_sql = include_str!("../assets/queries/tags/batch_remove_implicit_tags.sql") - .replace(":fileIds", &sql_file_ids) - .replace(":folderIds", &sql_folder_ids) - .replace(":implicitFromIds", &sql_implicit_from_ids); - con.execute_batch(&formatted_sql) + // 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 ================= diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index 31e48a8..2d12e3b 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -508,3 +508,151 @@ mod add_implicit_tag_to_files_tests { 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/test/mod.rs b/src/test/mod.rs index 8d9351e..bb2fd0d 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -143,6 +143,19 @@ mod tests { 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); From 358f0d3ee8974abd2df1100343aef38f292c205d Mon Sep 17 00:00:00 2001 From: ploiu Date: Mon, 24 Nov 2025 18:28:09 +0000 Subject: [PATCH 44/61] some cleanup --- src/model/api.rs | 8 ------- src/model/request/folder_requests.rs | 3 +++ src/service/file_service.rs | 35 ++++++++++++++-------------- 3 files changed, 21 insertions(+), 25 deletions(-) diff --git a/src/model/api.rs b/src/model/api.rs index 787985a..4d49703 100644 --- a/src/model/api.rs +++ b/src/model/api.rs @@ -6,14 +6,6 @@ use crate::model::file_types::FileTypes; use crate::model::repository::FileRecord; use crate::model::response::TaggedItemApi; -#[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, -} - #[derive(Deserialize, Serialize, Debug, Hash, Clone, Eq)] #[cfg_attr(not(test), derive(PartialEq))] #[serde(crate = "rocket::serde")] diff --git a/src/model/request/folder_requests.rs b/src/model/request/folder_requests.rs index 60772ae..bd607c8 100644 --- a/src/model/request/folder_requests.rs +++ b/src/model/request/folder_requests.rs @@ -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 { diff --git a/src/service/file_service.rs b/src/service/file_service.rs index 6f50797..7678a32 100644 --- a/src/service/file_service.rs +++ b/src/service/file_service.rs @@ -4,7 +4,7 @@ use std::ffi::OsStr; use std::fs::File; use std::fs::{self}; use std::os::unix::fs::MetadataExt; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::string::ToString; use once_cell::sync::Lazy; @@ -341,25 +341,29 @@ pub fn delete_file_by_id_with_connection(id: u32, con: &Connection) -> Result<() pub fn update_file(file: FileApi) -> Result { 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); } @@ -371,11 +375,7 @@ pub fn update_file(file: FileApi) -> Result { 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 - }; + let new_parent_id = file.folder_id.filter(|&it| it != 0); // 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); @@ -387,11 +387,12 @@ 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 From 45dd173cdd3e8b1bcdd78c99c4980cd9cce4c18d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 19:09:40 +0000 Subject: [PATCH 45/61] Initial plan From 8e23fb1040bc5e3f44847b44d4dac5627ffdb089 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 19:17:39 +0000 Subject: [PATCH 46/61] Add file_repository::get_all_ancestors function with tests Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- src/assets/queries/file/get_all_ancestors.sql | 30 +++++ src/repository/file_repository.rs | 103 ++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 src/assets/queries/file/get_all_ancestors.sql 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/repository/file_repository.rs b/src/repository/file_repository.rs index 819dbf7..0726ff4 100644 --- a/src/repository/file_repository.rs +++ b/src/repository/file_repository.rs @@ -162,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)?; @@ -794,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(); + } +} From 0fc5f8926650ee7c55811f416f09688b1a1b4ed3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 19:20:23 +0000 Subject: [PATCH 47/61] Add service layer functions for tag re-evaluation on file move Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- src/service/file_service.rs | 51 +++++++++++++++++++- src/tags/service.rs | 95 +++++++++++++++++++++++++++++++++++-- 2 files changed, 142 insertions(+), 4 deletions(-) diff --git a/src/service/file_service.rs b/src/service/file_service.rs index 7678a32..4e39f6a 100644 --- a/src/service/file_service.rs +++ b/src/service/file_service.rs @@ -24,6 +24,7 @@ use crate::model::response::folder_responses::FolderResponse; use crate::previews; use crate::repository::{file_repository, folder_repository, open_connection}; use crate::service::folder_service; +use crate::tags::repository as tag_repository; use crate::tags::service as tag_service; use crate::{queue, repository}; @@ -338,6 +339,46 @@ 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 + 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 { let mut file = file; // first check if the file exists @@ -374,8 +415,16 @@ 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 + + // 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 implicit tags from old ancestors before updating the file + remove_all_stale_ancestor_tags(file.id, &con)?; + } + + // now that we've verified that the file & folder exist and we're not gonna collide names, perform the move // 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); diff --git a/src/tags/service.rs b/src/tags/service.rs index af261a2..c634bca 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -167,14 +167,16 @@ pub fn delete_tag(id: u32) -> Result<(), DeleteTagError> { Ok(()) } -/// Updates the tags on a file by replacing all existing tags with the provided list. +/// Updates the tags on a file by replacing all existing explicit tags with the provided list. /// -/// Only explict tags can be managed this way. +/// 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 tags from the file +/// 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. @@ -239,6 +241,11 @@ pub fn update_file_tags(file_id: u32, tags: Vec) -> Result<(), Ta return Err(TagRelationError::DbError); } } + con.close().unwrap(); + + // Recalculate implied tags from ancestors + imply_all_ancestor_tags(file_id)?; + Ok(()) } @@ -531,3 +538,85 @@ pub fn pass_tags_to_children(folder_id: u32) -> Result<(), TagRelationError> { 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. +/// +/// 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_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 crate::repository::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 each ancestor's tags to the file + for ancestor_tag in ancestor_tags { + if let Err(e) = repository::add_implicit_tag_to_files( + ancestor_tag.tag_id, + &[file_id], + ancestor_id, + &con, + ) { + con.close().unwrap(); + log::error!( + "Failed to add implicit tag {ancestor_tag:?} to file {file_id}! Error is {e:?}\n{}", + Backtrace::force_capture() + ); + return Err(TagRelationError::DbError); + } + } + } + + con.close().unwrap(); + Ok(()) +} From 25ab9e7cf74ebc429baf9452a9b2cb671bf7950f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 19:28:11 +0000 Subject: [PATCH 48/61] Add comprehensive tests for file move tag re-evaluation Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- src/service/file_service.rs | 176 +++++++++++++++++++++++++++++++++++- src/tags/tests/service.rs | 132 +++++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 1 deletion(-) diff --git a/src/service/file_service.rs b/src/service/file_service.rs index 4e39f6a..4eee204 100644 --- a/src/service/file_service.rs +++ b/src/service/file_service.rs @@ -739,11 +739,14 @@ mod update_file_tests { 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] @@ -1080,6 +1083,177 @@ 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 + let con = open_connection(); + tag_repository::add_explicit_tag_to_folder(2, tag_id, &con).unwrap(); + con.close().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"); + let con2 = open_connection(); + tag_repository::add_explicit_tag_to_file(1, tag_id, &con2).unwrap(); + con2.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(); + } } #[cfg(test)] diff --git a/src/tags/tests/service.rs b/src/tags/tests/service.rs index 13d2b3e..62517ee 100644 --- a/src/tags/tests/service.rs +++ b/src/tags/tests/service.rs @@ -854,3 +854,135 @@ mod pass_tags_to_children_tests { 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 properly_implies_tags_in_depth_first_order() { + 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(); + } +} From beb230c1b48bb2716bb2c5dccd52e1eb88aae797 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 19:30:09 +0000 Subject: [PATCH 49/61] Fix outdated comment in update_file function Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- src/service/file_service.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/service/file_service.rs b/src/service/file_service.rs index 4eee204..eb971ea 100644 --- a/src/service/file_service.rs +++ b/src/service/file_service.rs @@ -424,7 +424,7 @@ pub fn update_file(file: FileApi) -> Result { remove_all_stale_ancestor_tags(file.id, &con)?; } - // now that we've verified that the file & folder exist and we're not gonna collide names, perform the move + // Update the file in the database // 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); From d59f61d88da4babede3a8fb62084e3cbcb7e14ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 19:32:38 +0000 Subject: [PATCH 50/61] Address code review nitpicks - improve code clarity Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- src/service/file_service.rs | 4 +++- src/tags/service.rs | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/service/file_service.rs b/src/service/file_service.rs index eb971ea..4c2ea5d 100644 --- a/src/service/file_service.rs +++ b/src/service/file_service.rs @@ -368,6 +368,7 @@ fn remove_all_stale_ancestor_tags( }; // 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{}", @@ -424,7 +425,8 @@ pub fn update_file(file: FileApi) -> Result { remove_all_stale_ancestor_tags(file.id, &con)?; } - // Update the file in the database + // Update the file in the database with new parent + // Note: Tag re-evaluation happens in update_file_tags() after this update // 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); diff --git a/src/tags/service.rs b/src/tags/service.rs index c634bca..59393c0 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -9,7 +9,7 @@ use crate::model::error::tag_errors::{ CreateTagError, DeleteTagError, GetTagError, TagRelationError, UpdateTagError, }; use crate::model::response::{TagApi, TaggedItemApi}; -use crate::repository::{folder_repository, open_connection}; +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; @@ -569,7 +569,7 @@ pub fn imply_all_ancestor_tags(file_id: u32) -> Result<(), TagRelationError> { let con = open_connection(); // Get all ancestors in depth-first order - let ancestor_ids = match crate::repository::file_repository::get_all_ancestors(file_id, &con) { + let ancestor_ids = match file_repository::get_all_ancestors(file_id, &con) { Ok(ids) => ids, Err(e) => { log::error!( From 65d7ca5cd1965a03dc120c79135f6be68456a794 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 20:27:35 +0000 Subject: [PATCH 51/61] Address PR review feedback: clean up comments, reuse connections, add add_implicit_tags_to_files function Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- src/service/file_service.rs | 10 ++-- src/tags/repository.rs | 32 +++++++++++++ src/tags/service.rs | 33 ++++++------- src/tags/tests/repository.rs | 93 ++++++++++++++++++++++++++++++++++++ src/tags/tests/service.rs | 2 +- 5 files changed, 143 insertions(+), 27 deletions(-) diff --git a/src/service/file_service.rs b/src/service/file_service.rs index 4c2ea5d..e037514 100644 --- a/src/service/file_service.rs +++ b/src/service/file_service.rs @@ -421,12 +421,10 @@ pub fn update_file(file: FileApi) -> Result { 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 implicit tags from old ancestors before updating the file remove_all_stale_ancestor_tags(file.id, &con)?; } // Update the file in the database with new parent - // Note: Tag re-evaluation happens in update_file_tags() after this update // 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); @@ -1181,17 +1179,15 @@ mod update_file_tests { // 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 + // 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(); - con.close().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"); - let con2 = open_connection(); - tag_repository::add_explicit_tag_to_file(1, tag_id, &con2).unwrap(); - con2.close().unwrap(); + 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 { diff --git a/src/tags/repository.rs b/src/tags/repository.rs index 9bb7c52..61f406a 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -112,6 +112,38 @@ pub fn add_implicit_tag_to_files( Ok(()) } +/// 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> { + let mut pst = con.prepare(include_str!( + "../assets/queries/tags/add_implicit_tag_to_file.sql" + ))?; + for file_id in file_ids { + for tag_id in tag_ids { + pst.execute(rusqlite::params![tag_id, file_id, implicit_from_id])?; + } + } + Ok(()) +} + /// Retrieves all tags on a file, explicit or implied /// /// ## Parameters: diff --git a/src/tags/service.rs b/src/tags/service.rs index 59393c0..26ce704 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -545,10 +545,6 @@ pub fn pass_tags_to_children(folder_id: u32) -> Result<(), TagRelationError> { /// explicit tags to the file. Tags are processed in depth-first order (closest /// ancestor first), ensuring that tags from closer ancestors take precedence. /// -/// 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_id`: the ID of the file to imply ancestor tags to /// @@ -599,21 +595,20 @@ pub fn imply_all_ancestor_tags(file_id: u32) -> Result<(), TagRelationError> { } }; - // Imply each ancestor's tags to the file - for ancestor_tag in ancestor_tags { - if let Err(e) = repository::add_implicit_tag_to_files( - ancestor_tag.tag_id, - &[file_id], - ancestor_id, - &con, - ) { - con.close().unwrap(); - log::error!( - "Failed to add implicit tag {ancestor_tag:?} to file {file_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); } } diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index 2d12e3b..e34b311 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -509,6 +509,99 @@ mod add_implicit_tag_to_files_tests { } } +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 works_with_empty_file_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_tags_to_files(&[], &[tag_id], 1, &con).unwrap(); + con.close().unwrap(); + cleanup(); + } + + #[test] + fn works_with_empty_tag_slice() { + 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(); + add_implicit_tags_to_files(&[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_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::{ diff --git a/src/tags/tests/service.rs b/src/tags/tests/service.rs index 62517ee..aa5e2fa 100644 --- a/src/tags/tests/service.rs +++ b/src/tags/tests/service.rs @@ -862,7 +862,7 @@ mod imply_all_ancestor_tags_tests { use crate::test::*; #[test] - fn properly_implies_tags_in_depth_first_order() { + fn implies_tags_from_all_ancestors() { init_db_folder(); // Create folder hierarchy: A -> B -> C create_folder_db_entry("A", None); // id 1 From cc76806b5f78b792ecb1eb1a53f46717ed4965e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 21:07:27 +0000 Subject: [PATCH 52/61] Refactor add_implicit_tags_to_files to follow batch_remove_implicit_tags pattern Co-authored-by: ploiu <43047560+ploiu@users.noreply.github.com> --- src/tags/repository.rs | 37 ++++++++++++++++++----- src/tags/tests/repository.rs | 58 +++++++++++++++++++++++++----------- 2 files changed, 71 insertions(+), 24 deletions(-) diff --git a/src/tags/repository.rs b/src/tags/repository.rs index 61f406a..03269fa 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -126,19 +126,42 @@ pub fn add_implicit_tag_to_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 +/// - `Ok(())` if the operation completed successfully _or_ if `file_ids` is empty _or_ if `tag_ids` is empty +/// - `Err(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> { - let mut pst = con.prepare(include_str!( - "../assets/queries/tags/add_implicit_tag_to_file.sql" - ))?; - for file_id in file_ids { - for tag_id in tag_ids { - pst.execute(rusqlite::params![tag_id, file_id, implicit_from_id])?; + // to prevent unnecessary work + if file_ids.is_empty() || tag_ids.is_empty() { + return Ok(()); + } + + // chunk file_ids and tag_ids to prevent exceeding SQLite's limits + let file_chunks = file_ids.chunks(999); + + for file_chunk in file_chunks { + let tag_chunks = tag_ids.chunks(999); + for tag_chunk in tag_chunks { + // Build a batch insert statement with multiple VALUES + let values: Vec = file_chunk + .iter() + .flat_map(|file_id| { + tag_chunk.iter().map(move |tag_id| { + format!("({tag_id}, {file_id}, {implicit_from_id})") + }) + }) + .collect(); + + let sql = format!( + "INSERT OR IGNORE INTO TaggedItems(tagId, fileId, implicitFromId) VALUES {}", + values.join(",") + ); + + log::debug!("add_implicit_tags_to_files sql: {sql}"); + con.execute(&sql, [])?; } } Ok(()) diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index e34b311..bc5f4c9 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -533,25 +533,34 @@ mod add_implicit_tags_to_files_tests { } #[test] - fn works_with_empty_file_slice() { + 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(); - add_implicit_tags_to_files(&[], &[tag_id], 1, &con).unwrap(); + // 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 works_with_empty_tag_slice() { + 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(); - add_implicit_tags_to_files(&[1], &[], 1, &con).unwrap(); + // 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); + assert_eq!(tags.len(), 0, "No tags should have been added"); con.close().unwrap(); cleanup(); } @@ -577,26 +586,41 @@ mod add_implicit_tags_to_files_tests { } #[test] - fn handles_large_numbers() { + fn handles_many_file_ids() { init_db_folder(); create_folder_db_entry("parent", None); // id 1 - // Create many files - for i in 1..=50 { + // Create 1001 files to cross the 999 chunk boundary + for i in 1..=1001 { create_file_db_entry(&format!("file{i}.txt"), Some(1)); } - // Create many tags + let tag_id = create_tag_db_entry("test_tag"); + let con = open_connection(); + let file_ids: Vec = (1..=1001).collect(); + add_implicit_tags_to_files(&file_ids, &[tag_id], 1, &con).unwrap(); + // Verify first and last files have the tag (ensuring both chunks processed) + let tags_first = get_all_tags_for_file(1, &con).unwrap(); + let tags_last = get_all_tags_for_file(1001, &con).unwrap(); + assert_eq!(tags_first.len(), 1); + assert_eq!(tags_last.len(), 1); + con.close().unwrap(); + cleanup(); + } + + #[test] + fn handles_many_tag_ids() { + init_db_folder(); + create_folder_db_entry("parent", None); // id 1 + create_file_db_entry("file.txt", Some(1)); // id 1 + // Create 1001 tags to cross the 999 chunk boundary let mut tag_ids = Vec::new(); - for i in 1..=20 { + for i in 1..=1001 { 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); - } + add_implicit_tags_to_files(&[1], &tag_ids, 1, &con).unwrap(); + // Verify file has all tags + let tags = get_all_tags_for_file(1, &con).unwrap(); + assert_eq!(tags.len(), 1001); con.close().unwrap(); cleanup(); } From 624337739acfa6e9a4ec3ee99d6fa4274b2b858f Mon Sep 17 00:00:00 2001 From: ploiu Date: Mon, 24 Nov 2025 21:26:22 +0000 Subject: [PATCH 53/61] revert cc76806b5f78b792ecb1eb1a53f46717ed4965e0 --- src/tags/repository.rs | 37 +++++------------------ src/tags/tests/repository.rs | 58 +++++++++++------------------------- 2 files changed, 24 insertions(+), 71 deletions(-) diff --git a/src/tags/repository.rs b/src/tags/repository.rs index 03269fa..61f406a 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -126,42 +126,19 @@ pub fn add_implicit_tag_to_files( /// - `con`: a reference to a database connection. The caller must manage closing the connection. /// /// ## Returns: -/// - `Ok(())` if the operation completed successfully _or_ if `file_ids` is empty _or_ if `tag_ids` is empty -/// - `Err(rusqlite::Error)` if a database interaction fails +/// 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> { - // to prevent unnecessary work - if file_ids.is_empty() || tag_ids.is_empty() { - return Ok(()); - } - - // chunk file_ids and tag_ids to prevent exceeding SQLite's limits - let file_chunks = file_ids.chunks(999); - - for file_chunk in file_chunks { - let tag_chunks = tag_ids.chunks(999); - for tag_chunk in tag_chunks { - // Build a batch insert statement with multiple VALUES - let values: Vec = file_chunk - .iter() - .flat_map(|file_id| { - tag_chunk.iter().map(move |tag_id| { - format!("({tag_id}, {file_id}, {implicit_from_id})") - }) - }) - .collect(); - - let sql = format!( - "INSERT OR IGNORE INTO TaggedItems(tagId, fileId, implicitFromId) VALUES {}", - values.join(",") - ); - - log::debug!("add_implicit_tags_to_files sql: {sql}"); - con.execute(&sql, [])?; + let mut pst = con.prepare(include_str!( + "../assets/queries/tags/add_implicit_tag_to_file.sql" + ))?; + for file_id in file_ids { + for tag_id in tag_ids { + pst.execute(rusqlite::params![tag_id, file_id, implicit_from_id])?; } } Ok(()) diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index bc5f4c9..e34b311 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -533,34 +533,25 @@ mod add_implicit_tags_to_files_tests { } #[test] - fn does_nothing_when_file_ids_is_empty() { + fn works_with_empty_file_slice() { 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"); + add_implicit_tags_to_files(&[], &[tag_id], 1, &con).unwrap(); con.close().unwrap(); cleanup(); } #[test] - fn does_nothing_when_tag_ids_is_empty() { + fn works_with_empty_tag_slice() { 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 + add_implicit_tags_to_files(&[1], &[], 1, &con).unwrap(); let tags = get_all_tags_for_file(1, &con).unwrap(); - assert_eq!(tags.len(), 0, "No tags should have been added"); + assert_eq!(tags.len(), 0); con.close().unwrap(); cleanup(); } @@ -586,41 +577,26 @@ mod add_implicit_tags_to_files_tests { } #[test] - fn handles_many_file_ids() { + fn handles_large_numbers() { init_db_folder(); create_folder_db_entry("parent", None); // id 1 - // Create 1001 files to cross the 999 chunk boundary - for i in 1..=1001 { + // Create many files + for i in 1..=50 { create_file_db_entry(&format!("file{i}.txt"), Some(1)); } - let tag_id = create_tag_db_entry("test_tag"); - let con = open_connection(); - let file_ids: Vec = (1..=1001).collect(); - add_implicit_tags_to_files(&file_ids, &[tag_id], 1, &con).unwrap(); - // Verify first and last files have the tag (ensuring both chunks processed) - let tags_first = get_all_tags_for_file(1, &con).unwrap(); - let tags_last = get_all_tags_for_file(1001, &con).unwrap(); - assert_eq!(tags_first.len(), 1); - assert_eq!(tags_last.len(), 1); - con.close().unwrap(); - cleanup(); - } - - #[test] - fn handles_many_tag_ids() { - init_db_folder(); - create_folder_db_entry("parent", None); // id 1 - create_file_db_entry("file.txt", Some(1)); // id 1 - // Create 1001 tags to cross the 999 chunk boundary + // Create many tags let mut tag_ids = Vec::new(); - for i in 1..=1001 { + 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(&[1], &tag_ids, 1, &con).unwrap(); - // Verify file has all tags - let tags = get_all_tags_for_file(1, &con).unwrap(); - assert_eq!(tags.len(), 1001); + 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(); } From 3f6ae8afc901059b950f11af640316a1b2528f8b Mon Sep 17 00:00:00 2001 From: ploiu Date: Mon, 24 Nov 2025 22:25:50 +0000 Subject: [PATCH 54/61] some performance fixes --- src/repository/file_repository.rs | 24 +++++----- src/service/file_service.rs | 77 +++++++++++++++--------------- src/tags/repository.rs | 55 ++++++++++++---------- src/tags/service.rs | 13 ++---- src/tags/tests/repository.rs | 17 +++++-- src/tags/tests/service.rs | 78 +++++++++++++++---------------- 6 files changed, 137 insertions(+), 127 deletions(-) diff --git a/src/repository/file_repository.rs b/src/repository/file_repository.rs index 0726ff4..55c4205 100644 --- a/src/repository/file_repository.rs +++ b/src/repository/file_repository.rs @@ -180,7 +180,7 @@ pub fn get_all_ancestors(file_id: u32, con: &Connection) -> Result, rus 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])?; @@ -853,17 +853,17 @@ mod get_all_ancestors_tests { 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_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(); @@ -874,11 +874,11 @@ mod get_all_ancestors_tests { 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(); } @@ -886,13 +886,13 @@ mod get_all_ancestors_tests { #[test] fn should_handle_single_parent() { init_db_folder(); - create_folder_db_entry("A", None); // id 1 + 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/service/file_service.rs b/src/service/file_service.rs index e037514..55985ee 100644 --- a/src/service/file_service.rs +++ b/src/service/file_service.rs @@ -351,10 +351,7 @@ pub fn delete_file_by_id_with_connection(id: u32, con: &Connection) -> Result<() /// ## 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> { +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, @@ -369,7 +366,9 @@ fn remove_all_stale_ancestor_tags( // 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) { + 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() @@ -416,14 +415,14 @@ pub fn update_file(file: FileApi) -> Result { file_dir(), file_repository::get_file_path(file.id, &con).unwrap() ); - + // 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)); @@ -1088,25 +1087,25 @@ mod update_file_tests { 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_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, @@ -1118,11 +1117,11 @@ mod update_file_tests { 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(); } @@ -1130,18 +1129,18 @@ mod update_file_tests { 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_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, @@ -1157,13 +1156,13 @@ mod update_file_tests { 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(); } @@ -1171,24 +1170,24 @@ mod update_file_tests { 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_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, @@ -1204,13 +1203,13 @@ mod update_file_tests { 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(); } @@ -1218,20 +1217,20 @@ mod update_file_tests { 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_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, @@ -1243,13 +1242,13 @@ mod update_file_tests { 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(); } } diff --git a/src/tags/repository.rs b/src/tags/repository.rs index 61f406a..6c0dcc6 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -2,6 +2,7 @@ use std::{backtrace::Backtrace, collections::HashMap}; use itertools::Itertools; use rusqlite::Connection; +use std::fmt::Write; use crate::tags::TagTypes; @@ -103,13 +104,7 @@ pub fn add_implicit_tag_to_files( implicit_from_id: u32, con: &Connection, ) -> Result<(), rusqlite::Error> { - let mut pst = con.prepare(include_str!( - "../assets/queries/tags/add_implicit_tag_to_file.sql" - ))?; - for file_id in file_ids { - pst.execute(rusqlite::params![tag_id, file_id, implicit_from_id])?; - } - Ok(()) + add_implicit_tags_to_files(file_ids, &[tag_id], implicit_from_id, con) } /// Adds multiple implicit tags to multiple files @@ -133,15 +128,38 @@ pub fn add_implicit_tags_to_files( implicit_from_id: u32, con: &Connection, ) -> Result<(), rusqlite::Error> { - let mut pst = con.prepare(include_str!( - "../assets/queries/tags/add_implicit_tag_to_file.sql" - ))?; + 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 { - pst.execute(rusqlite::params![tag_id, file_id, implicit_from_id])?; + // need to add a comma to the beginning of each line that isn't the first + if past_first { + sql.push(','); + } + write!(&mut sql, "({tag_id}, {file_id}, {implicit_from_id})\n") + .expect("writing to a string should never fail! You should never see this"); + past_first = true; } } - Ok(()) + con.execute_batch(&sql).and(Ok(())) } /// Retrieves all tags on a file, explicit or implied @@ -248,19 +266,6 @@ pub fn remove_explicit_tag_from_file( Ok(()) } -/// Deletes an implicit tag from a file if it exists -pub fn remove_implicit_tag_from_file( - tag_id: u32, - file_id: u32, - con: &Connection, -) -> Result<(), rusqlite::Error> { - let mut pst = con.prepare(include_str!( - "../assets/queries/tags/remove_implicit_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, diff --git a/src/tags/service.rs b/src/tags/service.rs index 26ce704..9442472 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -242,10 +242,10 @@ pub fn update_file_tags(file_id: u32, tags: Vec) -> Result<(), Ta } } con.close().unwrap(); - + // Recalculate implied tags from ancestors imply_all_ancestor_tags(file_id)?; - + Ok(()) } @@ -597,12 +597,9 @@ pub fn imply_all_ancestor_tags(file_id: u32) -> Result<(), TagRelationError> { // 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, - ) { + 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{}", diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index e34b311..7130163 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -533,23 +533,32 @@ mod add_implicit_tags_to_files_tests { } #[test] - fn works_with_empty_file_slice() { + 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(); - add_implicit_tags_to_files(&[], &[tag_id], 1, &con).unwrap(); + // 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 works_with_empty_tag_slice() { + 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(); - add_implicit_tags_to_files(&[1], &[], 1, &con).unwrap(); + // 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(); diff --git a/src/tags/tests/service.rs b/src/tags/tests/service.rs index aa5e2fa..6fbdbf9 100644 --- a/src/tags/tests/service.rs +++ b/src/tags/tests/service.rs @@ -865,35 +865,35 @@ mod imply_all_ancestor_tags_tests { 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 - + 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_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(); } @@ -901,32 +901,32 @@ mod imply_all_ancestor_tags_tests { 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 - + 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 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 + 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(); } @@ -935,24 +935,24 @@ mod imply_all_ancestor_tags_tests { 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(); } @@ -960,29 +960,29 @@ mod imply_all_ancestor_tags_tests { 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 - + 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 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(); } } From e95be8db44b528efaea9758e046f2853617ae8ee Mon Sep 17 00:00:00 2001 From: ploiu Date: Mon, 24 Nov 2025 22:30:51 +0000 Subject: [PATCH 55/61] fix test compile errors --- src/tags/tests/repository.rs | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/src/tags/tests/repository.rs b/src/tags/tests/repository.rs index 7130163..05fa7eb 100644 --- a/src/tags/tests/repository.rs +++ b/src/tags/tests/repository.rs @@ -376,31 +376,6 @@ mod get_tags_on_files_tests { } } -mod implicit_tag_tests { - use crate::repository::open_connection; - use crate::tags::repository::{get_all_tags_for_file, remove_implicit_tag_from_file}; - use crate::test::*; - - #[test] - fn delete_implicit_tag_from_file_works() { - init_db_folder(); - create_folder_db_entry("parent", None); // id 1 - create_file_db_entry("file.txt", Some(1)); - let tag_id = create_tag_db_entry("test_tag"); - let con = open_connection(); - // Add implicit tag - crate::test::imply_tag_on_file(tag_id, 1, 1); - let tags = get_all_tags_for_file(1, &con).unwrap(); - assert_eq!(tags.len(), 1); - // Delete the implicit tag - remove_implicit_tag_from_file(tag_id, 1, &con).unwrap(); - let tags = get_all_tags_for_file(1, &con).unwrap(); - assert_eq!(tags.len(), 0); - con.close().unwrap(); - 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}; From 2ecd47fb40b1042be6adacf41b7cf68d800d0bbb Mon Sep 17 00:00:00 2001 From: ploiu Date: Tue, 25 Nov 2025 01:36:06 +0000 Subject: [PATCH 56/61] updating file no longer sets implied tags as explicit --- src/service/file_service.rs | 65 ++++++++++++++++++++++++++++++++++++- src/tags/service.rs | 1 + 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/service/file_service.rs b/src/service/file_service.rs index 55985ee..6327929 100644 --- a/src/service/file_service.rs +++ b/src/service/file_service.rs @@ -20,6 +20,7 @@ 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}; @@ -380,6 +381,7 @@ fn remove_all_stale_ancestor_tags(file_id: u32, con: &Connection) -> Result<(), } 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 = repository::open_connection(); @@ -444,7 +446,13 @@ pub fn update_file(file: FileApi) -> Result { 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); } @@ -1251,6 +1259,61 @@ mod update_file_tests { 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.get(0).unwrap(); + assert_eq!(&explicit_tag.title, "explicitTag"); + assert_eq!(explicit_tag.implicit_from, None); + + cleanup(); + } } #[cfg(test)] diff --git a/src/tags/service.rs b/src/tags/service.rs index 9442472..7a59496 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -191,6 +191,7 @@ pub fn delete_tag(id: u32) -> Result<(), DeleteTagError> { /// - `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!( From 7421d74587eab25f30b704c824aae163b7a07b1e Mon Sep 17 00:00:00 2001 From: ploiu Date: Tue, 25 Nov 2025 01:59:04 +0000 Subject: [PATCH 57/61] fix bug where files are sticky with their implied tags --- src/service/folder_service.rs | 87 +++++++++++++++++++++++++++++++++-- src/tags/service.rs | 5 +- src/test/mod.rs | 1 - 3 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/service/folder_service.rs b/src/service/folder_service.rs index f89dec5..4f3730b 100644 --- a/src/service/folder_service.rs +++ b/src/service/folder_service.rs @@ -176,7 +176,14 @@ pub fn update_folder(folder: &UpdateFolderRequest) -> Result = 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(), @@ -579,10 +586,14 @@ fn handle_folder_move_for_tags( } }; - // For each old ancestor, remove all implicit tags from that ancestor on the descendants of the folder being moved + // 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, - &descendant_folders, + &folders_to_update, &original_ancestors, &con, ) { @@ -752,7 +763,7 @@ mod update_folder_tests { use crate::tags::service::{get_tags_on_file, update_file_tags}; use crate::test::{ cleanup, create_file_db_entry, create_folder_db_entry, create_folder_disk, - create_tag_folder, init_db_folder, + create_tag_folder, imply_tag_on_folder, init_db_folder, }; #[test] @@ -1425,6 +1436,74 @@ mod update_folder_tests { 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(); + } } #[cfg(test)] diff --git a/src/tags/service.rs b/src/tags/service.rs index 7a59496..89a455a 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -252,6 +252,8 @@ pub fn update_file_tags(file_id: u32, tags: Vec) -> Result<(), Ta /// 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`) @@ -263,7 +265,8 @@ pub fn update_file_tags(file_id: u32, tags: Vec) -> Result<(), Ta /// # 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) +/// 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 diff --git a/src/test/mod.rs b/src/test/mod.rs index bb2fd0d..f613983 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -135,7 +135,6 @@ mod tests { let sql = format!( "insert into TaggedItems(tagId, fileId, implicitFromId) values ({tag_id}, {file_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 From 98f5fe62a37a658d5ca827c87d981ade08124d42 Mon Sep 17 00:00:00 2001 From: ploiu Date: Tue, 25 Nov 2025 02:26:00 +0000 Subject: [PATCH 58/61] saving file implicates tags from parents --- src/service/file_service.rs | 95 +++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/src/service/file_service.rs b/src/service/file_service.rs index 6327929..6e3b934 100644 --- a/src/service/file_service.rs +++ b/src/service/file_service.rs @@ -240,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) @@ -1459,3 +1460,97 @@ mod determine_file_type_tests { assert_eq!(determine_file_type("test.Zip"), FileTypes::Archive); } } + +#[cfg(test)] +mod save_file_tests { + use crate::service::file_service::get_file_metadata; + use crate::tags::service as tag_service; + use crate::test::{ + cleanup, create_file_db_entry, create_file_disk, create_folder_db_entry, + create_folder_disk, create_tag_folder, init_db_folder, + }; + + #[test] + fn save_file_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 file in folder C + create_file_db_entry("test.txt", Some(3)); // file id 1 + create_file_disk("A/B/C/test.txt", "test"); + + // Simulate what save_file does: call update_file_tags with empty vec + // This should trigger implication of all ancestor tags + tag_service::update_file_tags(1, vec![]).unwrap(); + + // Verify the file has all ancestor tags implied + let file = get_file_metadata(1).unwrap(); + assert_eq!(file.tags.len(), 3); + + // 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 save_file_should_implicate_no_tags_if_ancestor_is_root() { + init_db_folder(); + // Create a file directly in root (folder_id = None or 0) + create_file_db_entry("test.txt", None); // file id 1 + create_file_disk("test.txt", "test"); + + // Simulate what save_file does: call update_file_tags with empty vec + tag_service::update_file_tags(1, vec![]).unwrap(); + + // Verify the file has no tags (root has no tags to imply) + let file = get_file_metadata(1).unwrap(); + assert_eq!( + file.tags.len(), + 0, + "File in root should have no implied tags" + ); + + cleanup(); + } +} From 0a3179251ca5d71ecd09b17a3fcae954f764b25a Mon Sep 17 00:00:00 2001 From: ploiu Date: Tue, 25 Nov 2025 02:39:45 +0000 Subject: [PATCH 59/61] fix file tests, create folder implicates tags --- src/service/file_service.rs | 94 ---------------------- src/service/folder_service.rs | 98 +++++++++++++++++++++++ src/test/file_handler_tests.rs | 138 +++++++++++++++++++++++++++++++++ 3 files changed, 236 insertions(+), 94 deletions(-) diff --git a/src/service/file_service.rs b/src/service/file_service.rs index 6e3b934..a768910 100644 --- a/src/service/file_service.rs +++ b/src/service/file_service.rs @@ -1460,97 +1460,3 @@ mod determine_file_type_tests { assert_eq!(determine_file_type("test.Zip"), FileTypes::Archive); } } - -#[cfg(test)] -mod save_file_tests { - use crate::service::file_service::get_file_metadata; - use crate::tags::service as tag_service; - use crate::test::{ - cleanup, create_file_db_entry, create_file_disk, create_folder_db_entry, - create_folder_disk, create_tag_folder, init_db_folder, - }; - - #[test] - fn save_file_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 file in folder C - create_file_db_entry("test.txt", Some(3)); // file id 1 - create_file_disk("A/B/C/test.txt", "test"); - - // Simulate what save_file does: call update_file_tags with empty vec - // This should trigger implication of all ancestor tags - tag_service::update_file_tags(1, vec![]).unwrap(); - - // Verify the file has all ancestor tags implied - let file = get_file_metadata(1).unwrap(); - assert_eq!(file.tags.len(), 3); - - // 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 save_file_should_implicate_no_tags_if_ancestor_is_root() { - init_db_folder(); - // Create a file directly in root (folder_id = None or 0) - create_file_db_entry("test.txt", None); // file id 1 - create_file_disk("test.txt", "test"); - - // Simulate what save_file does: call update_file_tags with empty vec - tag_service::update_file_tags(1, vec![]).unwrap(); - - // Verify the file has no tags (root has no tags to imply) - let file = get_file_metadata(1).unwrap(); - assert_eq!( - file.tags.len(), - 0, - "File in root should have no implied tags" - ); - - cleanup(); - } -} diff --git a/src/service/folder_service.rs b/src/service/folder_service.rs index 4f3730b..99d8db4 100644 --- a/src/service/folder_service.rs +++ b/src/service/folder_service.rs @@ -99,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), @@ -1525,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/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(); +} From dc0e28b8a678a33bfe533f4edb2f0d36801fa5a4 Mon Sep 17 00:00:00 2001 From: ploiu Date: Tue, 25 Nov 2025 02:53:30 +0000 Subject: [PATCH 60/61] update docs and version --- .github/copilot-instructions.md | 31 ++++++++++++++++++++++-------- .vscode/settings.json | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 6 ++++-- openapi.json | 34 +++++++++++++++++++++++---------- src/handler/api_handler.rs | 2 +- src/test/api_handler_tests.rs | 2 +- 8 files changed, 56 insertions(+), 25 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8bbd2a8..a8e11e6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -30,7 +30,8 @@ Currently runs flawlessly on a 3B. Windows support 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, split out into categories based on what the queries touch +- 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. - src/previews/* - all file preview functionality. Currently takes the first @@ -49,21 +50,29 @@ Currently runs flawlessly on a 3B. Windows support 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 +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: + +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 + - 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 +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 -### Example ``` - src - tags @@ -77,6 +86,7 @@ each function should get its own `mod` in the respective test file. If more than - repository.rs - service.rs ``` + # Queue hardware strength is limited, and generating previews takes about ~1 second on @@ -150,7 +160,12 @@ 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. + +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 + +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 4e29738..c1fde32 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,5 @@ "chat.notifyWindowOnConfirmation": false, "telemetry.feedback.enabled": false, "deno.enable": false, - "sql-formatter.uppercase": 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/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/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(); } From ba7fa449da018f4caebb74c9f8cd4cf45c420da5 Mon Sep 17 00:00:00 2001 From: ploiu Date: Tue, 25 Nov 2025 03:02:15 +0000 Subject: [PATCH 61/61] clippy --- src/repository/folder_repository.rs | 2 +- src/service/file_service.rs | 4 ++-- src/tags/models.rs | 2 +- src/tags/repository.rs | 14 ++++++++------ src/tags/service.rs | 4 ++-- src/tags/tests/service.rs | 30 ++++++++++++++--------------- src/test/folder_handler_tests.rs | 2 +- 7 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/repository/folder_repository.rs b/src/repository/folder_repository.rs index a349e86..979e391 100644 --- a/src/repository/folder_repository.rs +++ b/src/repository/folder_repository.rs @@ -133,7 +133,7 @@ pub fn get_child_files( 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().copied().collect(); + let ids: HashSet = ids.iter().copied().collect(); if ids.is_empty() { get_child_files_root(con) } else { diff --git a/src/service/file_service.rs b/src/service/file_service.rs index a768910..2d2c15e 100644 --- a/src/service/file_service.rs +++ b/src/service/file_service.rs @@ -4,7 +4,7 @@ use std::ffi::OsStr; use std::fs::File; use std::fs::{self}; use std::os::unix::fs::MetadataExt; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::string::ToString; use once_cell::sync::Lazy; @@ -1309,7 +1309,7 @@ mod update_file_tests { // The explicit tag should remain explicit assert_eq!(updated.tags.len(), 1); - let explicit_tag = updated.tags.get(0).unwrap(); + let explicit_tag = updated.tags.first().unwrap(); assert_eq!(&explicit_tag.title, "explicitTag"); assert_eq!(explicit_tag.implicit_from, None); diff --git a/src/tags/models.rs b/src/tags/models.rs index 82d888a..b9e6201 100644 --- a/src/tags/models.rs +++ b/src/tags/models.rs @@ -4,7 +4,7 @@ pub enum TagTypes { /// The tag was individually set on the file or folder Explicit, /// the tag was individually set on an ancestor folder - Implicit, + _Implicit, } /// represents a tag in the Tags table of the database. When referencing a tag _on_ a file / folder, use [`TaggedItem`] instead diff --git a/src/tags/repository.rs b/src/tags/repository.rs index 6c0dcc6..2df790b 100644 --- a/src/tags/repository.rs +++ b/src/tags/repository.rs @@ -154,7 +154,7 @@ pub fn add_implicit_tags_to_files( if past_first { sql.push(','); } - write!(&mut sql, "({tag_id}, {file_id}, {implicit_from_id})\n") + 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; } @@ -193,18 +193,20 @@ pub fn get_all_tags_for_file( /// ## 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. +/// 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( +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"), + 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)?; @@ -333,7 +335,7 @@ pub fn get_all_tags_for_folder( /// ## 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. +/// 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 @@ -346,7 +348,7 @@ pub fn get_tags_for_folder( TagTypes::Explicit => { include_str!("../assets/queries/tags/get_explicit_tags_for_folder.sql") } - TagTypes::Implicit => { + TagTypes::_Implicit => { include_str!("../assets/queries/tags/get_implicit_tags_for_folder.sql") } }; diff --git a/src/tags/service.rs b/src/tags/service.rs index 89a455a..9f3d27e 100644 --- a/src/tags/service.rs +++ b/src/tags/service.rs @@ -353,7 +353,7 @@ pub fn update_folder_tags( con.close().unwrap(); // Propagate tag changes to all descendants - pass_tags_to_children(folder_id)?; + pass_tags_to_descendants(folder_id)?; Ok(()) } @@ -424,7 +424,7 @@ pub fn get_tags_on_folder(folder_id: u32) -> Result, TagRelat /// ## 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_children(folder_id: u32) -> Result<(), TagRelationError> { +pub fn pass_tags_to_descendants(folder_id: u32) -> Result<(), TagRelationError> { // Verify folder exists if !folder_service::folder_exists(Some(folder_id)) { log::error!( diff --git a/src/tags/tests/service.rs b/src/tags/tests/service.rs index 6fbdbf9..11a7ae5 100644 --- a/src/tags/tests/service.rs +++ b/src/tags/tests/service.rs @@ -554,7 +554,7 @@ mod get_tags_on_folder_tests { } } -mod pass_tags_to_children_tests { +mod pass_tags_to_descendants_tests { use crate::repository::open_connection; use crate::tags::repository as tag_repository; @@ -576,7 +576,7 @@ mod pass_tags_to_children_tests { create_tag_folder("test_tag", 1); // Pass tags to children - pass_tags_to_children(1).unwrap(); + pass_tags_to_descendants(1).unwrap(); // Check child has implicit tag let child_tags = get_tags_on_folder(2).unwrap(); @@ -610,7 +610,7 @@ mod pass_tags_to_children_tests { create_tag_folder("test_tag", 1); // Pass tags to children - pass_tags_to_children(1).unwrap(); + pass_tags_to_descendants(1).unwrap(); // Check file in parent has implicit tag let file1_tags = get_tags_on_file(1).unwrap(); @@ -643,7 +643,7 @@ mod pass_tags_to_children_tests { con.close().unwrap(); // Pass tags to children - pass_tags_to_children(1).unwrap(); + pass_tags_to_descendants(1).unwrap(); // Check child still has explicit tag (not implicit) let child_tags = get_tags_on_folder(2).unwrap(); @@ -669,7 +669,7 @@ mod pass_tags_to_children_tests { con.close().unwrap(); // Pass tags to children - pass_tags_to_children(1).unwrap(); + pass_tags_to_descendants(1).unwrap(); // Check file still has explicit tag (not implicit) let file_tags = get_tags_on_file(1).unwrap(); @@ -690,7 +690,7 @@ mod pass_tags_to_children_tests { // Add tag to parent and propagate create_tag_folder("test_tag", 1); - pass_tags_to_children(1).unwrap(); + pass_tags_to_descendants(1).unwrap(); // Verify child has implicit tag let child_tags = get_tags_on_folder(2).unwrap(); @@ -702,7 +702,7 @@ mod pass_tags_to_children_tests { con.close().unwrap(); // Propagate the change - pass_tags_to_children(1).unwrap(); + pass_tags_to_descendants(1).unwrap(); // Check child no longer has the tag let child_tags = get_tags_on_folder(2).unwrap(); @@ -726,7 +726,7 @@ mod pass_tags_to_children_tests { 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_children(2).unwrap(); + pass_tags_to_descendants(2).unwrap(); // Child should inherit from parent (closer ancestor) let child_tags = get_tags_on_folder(3).unwrap(); @@ -759,13 +759,13 @@ mod pass_tags_to_children_tests { let con = open_connection(); tag_repository::add_explicit_tag_to_folder(3, tag_id, &con).unwrap(); con.close().unwrap(); - pass_tags_to_children(3).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_children(2).unwrap(); + pass_tags_to_descendants(2).unwrap(); // Bottom should still have it as explicit let bottom_tags = get_tags_on_folder(3).unwrap(); @@ -791,7 +791,7 @@ mod pass_tags_to_children_tests { let con = open_connection(); tag_repository::add_explicit_tag_to_folder(3, tag_id, &con).unwrap(); con.close().unwrap(); - pass_tags_to_children(3).unwrap(); + pass_tags_to_descendants(3).unwrap(); // File should inherit from bottom let file_tags = get_tags_on_file(1).unwrap(); @@ -802,7 +802,7 @@ mod pass_tags_to_children_tests { let con = open_connection(); tag_repository::add_explicit_tag_to_folder(2, tag_id, &con).unwrap(); con.close().unwrap(); - pass_tags_to_children(2).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(); @@ -828,9 +828,9 @@ mod pass_tags_to_children_tests { tag_repository::add_explicit_tag_to_folder(3, tag_id, &con).unwrap(); con.close().unwrap(); - pass_tags_to_children(1).unwrap(); - pass_tags_to_children(2).unwrap(); - pass_tags_to_children(3).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(); diff --git a/src/test/folder_handler_tests.rs b/src/test/folder_handler_tests.rs index 6548d52..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 {