Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
957b878
fix(search): apply TagFilter in search.files query
slvnlrt Mar 17, 2026
32a788d
feat(tags): implement tags.by_id, tags.ancestors, tags.children, file…
slvnlrt Mar 17, 2026
e135682
fix(tags): prevent duplicate tag applications on the same file
slvnlrt Mar 17, 2026
8ec131d
fix(tags,ui): make tag view files navigable and wire Overview search …
slvnlrt Mar 18, 2026
15cb764
feat(tags): render tag view using standard explorer with full File ob…
slvnlrt Mar 19, 2026
739e372
feat(tags): add unapply/delete actions, fix tag sync and Inspector UX
slvnlrt Mar 19, 2026
49a9347
refactor: extract shared useRefetchTagQueries hook
slvnlrt Mar 19, 2026
c23dfb0
fix(core): use current device slug instead of \"unknown-device\" fall…
slvnlrt Mar 20, 2026
db1f88f
fix(media): replace broken useJobDispatch with direct mutations
slvnlrt Mar 20, 2026
db710d9
fix(tags): address CodeRabbit review findings on tag system
slvnlrt Mar 24, 2026
1e4b06d
fix(migration): keep newest row (MAX id) when deduplicating tag appli…
slvnlrt Mar 24, 2026
1280987
revert(tags): restore independent tagModeActive state
slvnlrt Mar 24, 2026
7eae2fe
fix(tags): address second round of CodeRabbit review
slvnlrt Mar 24, 2026
f391972
fix(tags): skip rows with undecodable required fields instead of fabr…
slvnlrt Mar 24, 2026
70718be
fix(tags): remove broken optimistic update and alert() dialog
slvnlrt Mar 24, 2026
c3b0aa9
fix(tags): emit file events on tag delete, refetch files.by_id for in…
slvnlrt Mar 24, 2026
23ae6a1
fix(tags): add extension to root-level file paths, validate entry UUIDs
slvnlrt Mar 24, 2026
c357493
fix(tags): pre-index content rows to avoid O(n²) tag merge, require e…
slvnlrt Mar 24, 2026
6cd296c
fix(tags): secure FTS5 escaping, batch entry lookups for performance
slvnlrt Mar 25, 2026
a0c5009
fix(tags): remove redundant inline sea_orm imports
slvnlrt Mar 25, 2026
296e7d7
fix(tags): validate entry UUIDs in create action before applying
slvnlrt Apr 12, 2026
b8e6aaf
fix: address code review feedback from CodeRabbit
slvnlrt Apr 15, 2026
2fbd6b2
fix: validate create tag targets, confirm before delete, escape LIKE …
slvnlrt Apr 15, 2026
35b165c
fix: prevent tagging ephemeral files and improve empty tag view UX
slvnlrt Apr 15, 2026
24be53f
fix: platform confirm, parameterized SQL, remove dead code, handle se…
slvnlrt Apr 16, 2026
2686561
fix: use native Tauri dialog for confirm on Windows (WebView2 broken)
slvnlrt Apr 16, 2026
dffc350
fix: prevent double callback in platform.confirm and distinguish tag …
slvnlrt Apr 16, 2026
d5b5155
fix: separate missing-target, null-UUID, and execution errors in tag …
slvnlrt Apr 16, 2026
eb663ca
Merge main into tags-and-media-fixes
jamiepine Apr 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();

// Remove duplicate (user_metadata_id, tag_id) pairs, keeping the newest (MAX id)
// which has the most recent version/updated_at/device_uuid state.
// This must run before creating the unique index.
db.execute_unprepared(
"DELETE FROM user_metadata_tag \
WHERE id NOT IN ( \
SELECT MAX(id) FROM user_metadata_tag \
GROUP BY user_metadata_id, tag_id \
)",
)
.await?;

// Add unique index so the pair can never be duplicated again.
manager
.create_index(
Index::create()
.if_not_exists()
.name("idx_umt_unique_pair")
.table(Alias::new("user_metadata_tag"))
.col(Alias::new("user_metadata_id"))
.col(Alias::new("tag_id"))
.unique()
.to_owned(),
)
.await?;

Ok(())
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_index(
Index::drop()
.name("idx_umt_unique_pair")
.table(Alias::new("user_metadata_tag"))
.to_owned(),
)
.await?;

Ok(())
}
}
2 changes: 2 additions & 0 deletions core/src/infra/db/migration/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ mod m20260104_000001_replace_device_id_with_volume_id;
mod m20260105_000001_add_volume_id_to_locations;
mod m20260114_000001_fix_search_index_include_directories;
mod m20260123_000001_remove_legacy_sync_columns;
mod m20260125_000001_unique_user_metadata_tag;

pub struct Migrator;

Expand Down Expand Up @@ -81,6 +82,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260105_000001_add_volume_id_to_locations::Migration),
Box::new(m20260114_000001_fix_search_index_include_directories::Migration),
Box::new(m20260123_000001_remove_legacy_sync_columns::Migration),
Box::new(m20260125_000001_unique_user_metadata_tag::Migration),
]
}
}
77 changes: 42 additions & 35 deletions core/src/ops/media/thumbnail/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ use crate::{
ops::indexing::{path_resolver::PathResolver, processor::ProcessorEntry},
};
use specta::Type;
use std::path::PathBuf;
use std::sync::Arc;
use uuid::Uuid;

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Type)]
pub struct ThumbnailInput {
pub paths: Vec<std::path::PathBuf>,
pub paths: Vec<PathBuf>,
pub size: u32,
pub quality: u8,
}
Expand All @@ -25,13 +26,11 @@ pub struct ThumbnailAction {
}

impl ThumbnailAction {
/// Create a new thumbnail generation action
pub fn new(input: ThumbnailInput) -> Self {
Self { input }
}
}

// Implement the unified LibraryAction (replaces ActionHandler)
impl LibraryAction for ThumbnailAction {
type Input = ThumbnailInput;
type Output = crate::infra::job::handle::JobReceipt;
Expand All @@ -45,19 +44,13 @@ impl LibraryAction for ThumbnailAction {
library: std::sync::Arc<crate::library::Library>,
_context: Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
// Create thumbnail job config from size
let config = ThumbnailJobConfig::from_sizes(vec![self.input.size]);

// Create job instance directly
let job = ThumbnailJob::new(config);

// Dispatch job and return handle directly
let job_handle = library
.jobs()
.dispatch(job)
.await
.map_err(ActionError::Job)?;

Ok(job_handle.into())
}

Expand All @@ -66,7 +59,6 @@ impl LibraryAction for ThumbnailAction {
}
}

// Register action
crate::register_library_action!(ThumbnailAction, "media.thumbnail");

// ============================================================================
Expand All @@ -85,9 +77,7 @@ pub struct RegenerateThumbnailInput {

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Type)]
pub struct RegenerateThumbnailOutput {
/// Number of thumbnails generated
pub generated_count: usize,
/// Variant names that were generated
pub variants: Vec<String>,
}

Expand Down Expand Up @@ -133,31 +123,29 @@ impl LibraryAction for RegenerateThumbnailAction {
.await
.map_err(|e| ActionError::Internal(format!("Failed to resolve path: {}", e)))?;

// Get MIME type
// Get MIME type: try content_identity first, fall back to extension
let mime_type = if let Some(content_id) = entry.content_id {
if let Ok(Some(ci)) = entities::content_identity::Entity::find_by_id(content_id)
.one(db)
.await
if let Ok(Some(ci)) =
entities::content_identity::Entity::find_by_id(content_id)
.one(db)
.await
{
if let Some(mime_id) = ci.mime_type_id {
if let Ok(Some(mime)) = entities::mime_type::Entity::find_by_id(mime_id)
entities::mime_type::Entity::find_by_id(mime_id)
.one(db)
.await
{
Some(mime.mime_type)
} else {
None
}
.ok()
.flatten()
.map(|m| m.mime_type)
.or_else(|| mime_from_extension(&path))
} else {
None
mime_from_extension(&path)
}
} else {
None
mime_from_extension(&path)
}
} else {
return Err(ActionError::Internal(
"Entry has no content identity".to_string(),
));
mime_from_extension(&path)
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Build processor entry
Expand All @@ -178,28 +166,23 @@ impl LibraryAction for RegenerateThumbnailAction {
mime_type: mime_type.clone(),
};

// Create thumbnail processor with custom settings
// Create thumbnail processor
let mut processor =
ThumbnailProcessor::new(library.clone()).with_regenerate(self.input.force);

// Apply custom variants if provided
if let Some(variant_names) = &self.input.variants {
let settings = serde_json::json!({
"variants": variant_names,
});
let settings = serde_json::json!({ "variants": variant_names });
processor = processor
.with_settings(&settings)
.map_err(|e| ActionError::Internal(format!("Invalid settings: {}", e)))?;
}

// Check if processor should run
if !processor.should_process(&proc_entry) {
return Err(ActionError::Internal(
"File type does not support thumbnails".to_string(),
));
}

// Process the file - will fail with proper error if video without ffmpeg
let result = processor
.process(db, &proc_entry)
.await
Expand All @@ -211,7 +194,6 @@ impl LibraryAction for RegenerateThumbnailAction {
));
}

// Get variant names
let variant_names: Vec<String> = processor
.variants
.iter()
Expand All @@ -230,3 +212,28 @@ impl LibraryAction for RegenerateThumbnailAction {
}

crate::register_library_action!(RegenerateThumbnailAction, "media.thumbnail.regenerate");

/// Infer MIME type from file extension
fn mime_from_extension(path: &std::path::Path) -> Option<String> {
path.extension()
.and_then(|ext| ext.to_str())
.and_then(|ext| match ext.to_lowercase().as_str() {
"jpg" | "jpeg" => Some("image/jpeg"),
"png" => Some("image/png"),
"gif" => Some("image/gif"),
"webp" => Some("image/webp"),
"bmp" => Some("image/bmp"),
"svg" => Some("image/svg+xml"),
"tiff" | "tif" => Some("image/tiff"),
"avif" => Some("image/avif"),
"heic" | "heif" => Some("image/heif"),
"mp4" => Some("video/mp4"),
"mkv" => Some("video/x-matroska"),
"avi" => Some("video/x-msvideo"),
"mov" => Some("video/quicktime"),
"webm" => Some("video/webm"),
"pdf" => Some("application/pdf"),
_ => None,
})
.map(|s| s.to_string())
}
103 changes: 48 additions & 55 deletions core/src/ops/metadata/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ use crate::ops::tags::manager::TagManager;
use anyhow::Result;
use chrono::Utc;
use sea_orm::DatabaseConnection;
use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, EntityTrait, NotSet, QueryFilter, Set};
use sea_orm::{
ActiveModelTrait, ColumnTrait, DbConn, EntityTrait, NotSet, QueryFilter, Set,
sea_query::{Expr, OnConflict},
};
use std::collections::HashMap;
use std::sync::Arc;
use uuid::Uuid;
Expand Down Expand Up @@ -246,75 +249,65 @@ impl UserMetadataManager {
let uuid_to_db_id: HashMap<Uuid, i32> =
tag_models.into_iter().map(|m| (m.uuid, m.id)).collect();

// Insert tag applications
// Atomic upsert: INSERT ... ON CONFLICT(user_metadata_id, tag_id) DO UPDATE
for app in tag_applications {
if let Some(&tag_db_id) = uuid_to_db_id.get(&app.tag_id) {
let tag_application = user_metadata_tag::ActiveModel {
let instance_attributes_value = if app.instance_attributes.is_empty() {
None
} else {
Some(serde_json::to_value(&app.instance_attributes).unwrap().into())
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

let now = Utc::now();
let new_model = user_metadata_tag::ActiveModel {
id: NotSet,
user_metadata_id: Set(metadata_db_id),
tag_id: Set(tag_db_id),
applied_context: Set(app.applied_context.clone()),
applied_variant: Set(app.applied_variant.clone()),
confidence: Set(app.confidence),
source: Set(app.source.as_str().to_string()),
instance_attributes: Set(if app.instance_attributes.is_empty() {
None
} else {
Some(
serde_json::to_value(&app.instance_attributes)
.unwrap()
.into(),
)
}),
instance_attributes: Set(instance_attributes_value),
created_at: Set(app.created_at),
updated_at: Set(Utc::now()),
updated_at: Set(now),
device_uuid: Set(device_uuid),
uuid: Set(Uuid::new_v4()),
version: Set(1),
};

// Insert or update if exists
let model = match tag_application.clone().insert(&*db).await {
Ok(model) => model,
Err(_) => {
// If insert fails due to unique constraint, update existing
let existing = user_metadata_tag::Entity::find()
.filter(user_metadata_tag::Column::UserMetadataId.eq(metadata_db_id))
.filter(user_metadata_tag::Column::TagId.eq(tag_db_id))
.one(&*db)
.await
.map_err(|e| TagError::DatabaseError(e.to_string()))?;

if let Some(existing_model) = existing {
let mut update_model: user_metadata_tag::ActiveModel =
existing_model.into();
update_model.applied_context = Set(app.applied_context.clone());
update_model.applied_variant = Set(app.applied_variant.clone());
update_model.confidence = Set(app.confidence);
update_model.source = Set(app.source.as_str().to_string());
update_model.instance_attributes =
Set(if app.instance_attributes.is_empty() {
None
} else {
Some(
serde_json::to_value(&app.instance_attributes)
.unwrap()
.into(),
)
});
update_model.updated_at = Set(Utc::now());
update_model.device_uuid = Set(device_uuid);
update_model.version = Set(update_model.version.unwrap() + 1);

update_model
.update(&*db)
.await
.map_err(|e| TagError::DatabaseError(e.to_string()))?
} else {
continue;
}
}
};
let on_conflict = OnConflict::columns([
user_metadata_tag::Column::UserMetadataId,
user_metadata_tag::Column::TagId,
])
.update_columns([
user_metadata_tag::Column::AppliedContext,
user_metadata_tag::Column::AppliedVariant,
user_metadata_tag::Column::Confidence,
user_metadata_tag::Column::Source,
user_metadata_tag::Column::InstanceAttributes,
user_metadata_tag::Column::UpdatedAt,
user_metadata_tag::Column::DeviceUuid,
])
.value(
user_metadata_tag::Column::Version,
Expr::col(user_metadata_tag::Column::Version).add(1),
)
.to_owned();

user_metadata_tag::Entity::insert(new_model)
.on_conflict(on_conflict)
.exec(&*db)
.await
.map_err(|e| TagError::DatabaseError(e.to_string()))?;

// Re-query to get the final model (handles both insert and update cases)
let model = user_metadata_tag::Entity::find()
.filter(user_metadata_tag::Column::UserMetadataId.eq(metadata_db_id))
.filter(user_metadata_tag::Column::TagId.eq(tag_db_id))
.one(&*db)
.await
.map_err(|e| TagError::DatabaseError(e.to_string()))?
.ok_or_else(|| TagError::DatabaseError("Upsert succeeded but row not found".to_string()))?;
Comment thread
slvnlrt marked this conversation as resolved.
Outdated

created_models.push(model);
}
Expand Down
Loading