diff --git a/backend/database.go b/backend/database.go index 2ba760c..0b65fe7 100644 --- a/backend/database.go +++ b/backend/database.go @@ -136,14 +136,29 @@ type assetFilter struct { aliased bool } -func buildAssetFilter(userID, albumID string, withGPS bool, hiddenFilter, startDate, endDate string) assetFilter { +func buildAssetFilter(userID, albumID, tagID string, withGPS bool, hiddenFilter, startDate, endDate string) assetFilter { var f assetFilter - if albumID != "" { + if albumID != "" || tagID != "" { f.aliased = true - f.fromClause = `FROM assets a - JOIN albumAssets aa ON aa.userID = a.userID AND aa.assetID = a.immichID - WHERE a.userID = ? AND aa.albumID = ?` - f.args = append(f.args, userID, albumID) + f.fromClause = `FROM assets a` + if albumID != "" { + f.fromClause += ` + JOIN albumAssets aa ON aa.userID = a.userID AND aa.assetID = a.immichID` + } + if tagID != "" { + f.fromClause += ` + JOIN assetTags at ON at.userID = a.userID AND at.assetID = a.immichID` + } + f.fromClause += ` WHERE a.userID = ?` + f.args = append(f.args, userID) + if albumID != "" { + f.fromClause += ` AND aa.albumID = ?` + f.args = append(f.args, albumID) + } + if tagID != "" { + f.fromClause += ` AND at.tagID = ?` + f.args = append(f.args, tagID) + } if withGPS { f.fromClause += ` AND a.latitude IS NOT NULL AND a.longitude IS NOT NULL` } else { @@ -197,8 +212,8 @@ func buildAssetFilter(userID, albumID string, withGPS bool, hiddenFilter, startD return f } -func (d *Database) getFilteredAssets(ctx context.Context, userID, albumID string, withGPS bool, hiddenFilter, startDate, endDate string, page, pageSize int) ([]AssetRow, error) { - f := buildAssetFilter(userID, albumID, withGPS, hiddenFilter, startDate, endDate) +func (d *Database) getFilteredAssets(ctx context.Context, userID, albumID, tagID string, withGPS bool, hiddenFilter, startDate, endDate string, page, pageSize int) ([]AssetRow, error) { + f := buildAssetFilter(userID, albumID, tagID, withGPS, hiddenFilter, startDate, endDate) cols := assetColumns orderPrefix := "" @@ -220,8 +235,8 @@ func (d *Database) getFilteredAssets(ctx context.Context, userID, albumID string return scanAssetRows(rows) } -func (d *Database) countFilteredAssets(ctx context.Context, userID, albumID string, withGPS bool, hiddenFilter, startDate, endDate string) (int, error) { - f := buildAssetFilter(userID, albumID, withGPS, hiddenFilter, startDate, endDate) +func (d *Database) countFilteredAssets(ctx context.Context, userID, albumID, tagID string, withGPS bool, hiddenFilter, startDate, endDate string) (int, error) { + f := buildAssetFilter(userID, albumID, tagID, withGPS, hiddenFilter, startDate, endDate) query := `SELECT COUNT(*) ` + f.fromClause var count int @@ -229,8 +244,8 @@ func (d *Database) countFilteredAssets(ctx context.Context, userID, albumID stri return count, err } -func (d *Database) countAssetsByDay(ctx context.Context, userID, albumID string, withGPS bool, hiddenFilter, startDate, endDate string) (map[string]int, error) { - f := buildAssetFilter(userID, albumID, withGPS, hiddenFilter, startDate, endDate) +func (d *Database) countAssetsByDay(ctx context.Context, userID, albumID, tagID string, withGPS bool, hiddenFilter, startDate, endDate string) (map[string]int, error) { + f := buildAssetFilter(userID, albumID, tagID, withGPS, hiddenFilter, startDate, endDate) dateCol := "dateTimeOriginal" if f.aliased { @@ -263,15 +278,31 @@ type markerFilter struct { prefix string } -func buildMarkerFilter(userID, albumID string, bounds *TViewportBounds) markerFilter { +func buildMarkerFilter(userID, albumID, tagID, startDate, endDate string, bounds *TViewportBounds) markerFilter { var f markerFilter - if albumID != "" { + if albumID != "" || tagID != "" { f.prefix = "a." - f.fromClause = `FROM assets a - JOIN albumAssets aa ON aa.userID = a.userID AND aa.assetID = a.immichID - WHERE a.userID = ? AND aa.albumID = ? AND a.latitude IS NOT NULL AND a.longitude IS NOT NULL + f.fromClause = `FROM assets a` + if albumID != "" { + f.fromClause += ` + JOIN albumAssets aa ON aa.userID = a.userID AND aa.assetID = a.immichID` + } + if tagID != "" { + f.fromClause += ` + JOIN assetTags at ON at.userID = a.userID AND at.assetID = a.immichID` + } + f.fromClause += ` WHERE a.userID = ?` + f.args = append(f.args, userID) + if albumID != "" { + f.fromClause += ` AND aa.albumID = ?` + f.args = append(f.args, albumID) + } + if tagID != "" { + f.fromClause += ` AND at.tagID = ?` + f.args = append(f.args, tagID) + } + f.fromClause += ` AND a.latitude IS NOT NULL AND a.longitude IS NOT NULL AND a.stackPrimaryAssetID IS NULL` + hiddenLibraryFilterAliased - f.args = append(f.args, userID, albumID) } else { f.fromClause = `FROM assets WHERE userID = ? AND latitude IS NOT NULL AND longitude IS NOT NULL @@ -279,6 +310,15 @@ func buildMarkerFilter(userID, albumID string, bounds *TViewportBounds) markerFi f.args = append(f.args, userID) } + if startDate != "" { + f.fromClause += fmt.Sprintf(` AND %sdateTimeOriginal >= ?`, f.prefix) + f.args = append(f.args, startDate) + } + if endDate != "" { + f.fromClause += fmt.Sprintf(` AND %sdateTimeOriginal < ?`, f.prefix) + f.args = append(f.args, endDate+"T99") + } + if bounds != nil { f.fromClause += fmt.Sprintf(` AND %slatitude BETWEEN ? AND ?`, f.prefix) f.args = append(f.args, bounds.South, bounds.North) @@ -292,8 +332,8 @@ func buildMarkerFilter(userID, albumID string, bounds *TViewportBounds) markerFi return f } -func (d *Database) getMapMarkers(ctx context.Context, userID, albumID string, bounds *TViewportBounds, limit int) ([]MapMarker, error) { - f := buildMarkerFilter(userID, albumID, bounds) +func (d *Database) getMapMarkers(ctx context.Context, userID, albumID, tagID, startDate, endDate string, bounds *TViewportBounds, limit int) ([]MapMarker, error) { + f := buildMarkerFilter(userID, albumID, tagID, startDate, endDate, bounds) selectCols := "immichID, latitude, longitude" if f.prefix != "" { @@ -321,8 +361,8 @@ func (d *Database) getMapMarkers(ctx context.Context, userID, albumID string, bo return markers, rows.Err() } -func (d *Database) countMapMarkers(ctx context.Context, userID, albumID string, bounds *TViewportBounds) (int, error) { - f := buildMarkerFilter(userID, albumID, bounds) +func (d *Database) countMapMarkers(ctx context.Context, userID, albumID, tagID, startDate, endDate string, bounds *TViewportBounds) (int, error) { + f := buildMarkerFilter(userID, albumID, tagID, startDate, endDate, bounds) query := "SELECT COUNT(*) " + f.fromClause var count int @@ -538,7 +578,7 @@ func (d *Database) computeFrequentLocationClusters(ctx context.Context, userID s return clusters, rows.Err() } -func (d *Database) getAssetPageInfo(ctx context.Context, userID, assetID string, albumID string, pageSize int) (*AssetPageInfo, error) { +func (d *Database) getAssetPageInfo(ctx context.Context, userID, assetID string, albumID, tagID string, pageSize int) (*AssetPageInfo, error) { var fileCreatedAt string err := d.db.QueryRowContext(ctx, "SELECT fileCreatedAt FROM assets WHERE immichID = ? AND userID = ?"+hiddenLibraryFilter, assetID, userID).Scan(&fileCreatedAt) if err != nil { @@ -555,17 +595,45 @@ func (d *Database) getAssetPageInfo(ctx context.Context, userID, assetID string, } } + if tagID != "" { + var inTag bool + err := d.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM assetTags WHERE userID = ? AND assetID = ? AND tagID = ?)`, + userID, assetID, tagID, + ).Scan(&inTag) + if err != nil { + return nil, fmt.Errorf("failed to check tag membership: %w", err) + } + if !inTag { + tagID = "" + } + } + var position int - if albumID != "" { - err = d.db.QueryRowContext(ctx, - `SELECT COUNT(*) FROM assets a - JOIN albumAssets aa ON aa.userID = a.userID AND aa.assetID = a.immichID - WHERE a.userID = ? AND aa.albumID = ? - AND a.latitude IS NOT NULL AND a.longitude IS NOT NULL + if albumID != "" || tagID != "" { + query := `SELECT COUNT(*) FROM assets a` + var args []interface{} + if albumID != "" { + query += ` JOIN albumAssets aa ON aa.userID = a.userID AND aa.assetID = a.immichID` + } + if tagID != "" { + query += ` JOIN assetTags at ON at.userID = a.userID AND at.assetID = a.immichID` + } + query += ` WHERE a.userID = ?` + args = append(args, userID) + if albumID != "" { + query += ` AND aa.albumID = ?` + args = append(args, albumID) + } + if tagID != "" { + query += ` AND at.tagID = ?` + args = append(args, tagID) + } + query += ` AND a.latitude IS NOT NULL AND a.longitude IS NOT NULL AND a.stackPrimaryAssetID IS NULL - AND (a.fileCreatedAt > ? OR (a.fileCreatedAt = ? AND a.immichID > ?))`+hiddenLibraryFilterAliased, - userID, albumID, fileCreatedAt, fileCreatedAt, assetID, - ).Scan(&position) + AND (a.fileCreatedAt > ? OR (a.fileCreatedAt = ? AND a.immichID > ?))` + hiddenLibraryFilterAliased + args = append(args, fileCreatedAt, fileCreatedAt, assetID) + err = d.db.QueryRowContext(ctx, query, args...).Scan(&position) } else { err = d.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM assets @@ -829,6 +897,8 @@ func (d *Database) deleteUserSyncData(ctx context.Context, userID string) error "dawarichTracks", "albumAssets", "albums", + "assetTags", + "tags", "frequentLocations", "favoritePlaces", "assets", diff --git a/backend/databaseAlbums.go b/backend/databaseAlbums.go index 95b283b..0172171 100644 --- a/backend/databaseAlbums.go +++ b/backend/databaseAlbums.go @@ -45,6 +45,14 @@ func (d *Database) upsertAlbum(ctx context.Context, userID, albumID, albumName s return err } +func (d *Database) setAlbumSynced(ctx context.Context, userID, albumID, updatedAt string) error { + _, err := d.db.ExecContext(ctx, + `UPDATE albums SET updatedAt = ? WHERE userID = ? AND immichID = ?`, + updatedAt, userID, albumID, + ) + return err +} + func (d *Database) replaceAlbumAssets(ctx context.Context, userID, albumID string, assetIDs []string) error { tx, err := d.db.BeginTx(ctx, nil) if err != nil { diff --git a/backend/databaseLibraries_test.go b/backend/databaseLibraries_test.go index 4d33090..f2d4e47 100644 --- a/backend/databaseLibraries_test.go +++ b/backend/databaseLibraries_test.go @@ -286,7 +286,7 @@ func TestHiddenLibraryAssetsExcludedFromMapMarkers(t *testing.T) { db.upsertLibrary(ctx, "lib1", "External", 1) db.updateLibraryVisibility(ctx, "lib1", true) - markers, err := db.getMapMarkers(ctx, testUserID, "", nil, maxMapMarkers) + markers, err := db.getMapMarkers(ctx, testUserID, "", "", "", "", nil, maxMapMarkers) if err != nil { t.Fatalf("getMapMarkers: %v", err) } @@ -305,7 +305,7 @@ func TestHiddenLibraryAssetsExcludedFromFilteredAssets(t *testing.T) { db.upsertLibrary(ctx, "lib1", "External", 1) db.updateLibraryVisibility(ctx, "lib1", true) - assets, err := db.getFilteredAssets(ctx, testUserID, "", true, "all", "", "", 1, 10) + assets, err := db.getFilteredAssets(ctx, testUserID, "", "", true, "all", "", "", 1, 10) if err != nil { t.Fatalf("getFilteredAssets: %v", err) } @@ -313,7 +313,7 @@ func TestHiddenLibraryAssetsExcludedFromFilteredAssets(t *testing.T) { t.Errorf("expected 1 filtered asset, got %d", len(assets)) } - count, err := db.countFilteredAssets(ctx, testUserID, "", true, "all", "", "") + count, err := db.countFilteredAssets(ctx, testUserID, "", "", true, "all", "", "") if err != nil { t.Fatalf("countFilteredAssets: %v", err) } @@ -355,7 +355,7 @@ func TestUnknownLibraryAssetsVisible(t *testing.T) { t.Errorf("expected 3 assets, got %d", total) } - markers, _ := db.getMapMarkers(ctx, testUserID, "", nil, maxMapMarkers) + markers, _ := db.getMapMarkers(ctx, testUserID, "", "", "", "", nil, maxMapMarkers) if len(markers) != 3 { t.Errorf("expected 3 markers, got %d", len(markers)) } diff --git a/backend/databaseTags.go b/backend/databaseTags.go new file mode 100644 index 0000000..c10a0a7 --- /dev/null +++ b/backend/databaseTags.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "fmt" +) + +func (d *Database) getTagUpdatedAtMap(ctx context.Context, userID string) (map[string]string, error) { + result := make(map[string]string) + rows, err := d.db.QueryContext(ctx, "SELECT immichID, updatedAt FROM tags WHERE userID = ?", userID) + if err != nil { + return nil, fmt.Errorf("failed to query tag updatedAt: %w", err) + } + defer rows.Close() + for rows.Next() { + var id, updatedAt string + if err := rows.Scan(&id, &updatedAt); err != nil { + return nil, fmt.Errorf("failed to scan tag updatedAt: %w", err) + } + result[id] = updatedAt + } + return result, rows.Err() +} + +func (d *Database) upsertTag(ctx context.Context, userID, tagID, name, value string, parentID, color *string) error { + _, err := d.db.ExecContext(ctx, + `INSERT INTO tags (userID, immichID, name, value, parentID, color, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, '') + ON CONFLICT(userID, immichID) DO UPDATE SET + name = excluded.name, + value = excluded.value, + parentID = excluded.parentID, + color = excluded.color`, + userID, tagID, name, value, parentID, color, + ) + return err +} + +func (d *Database) setTagSynced(ctx context.Context, userID, tagID, updatedAt string) error { + _, err := d.db.ExecContext(ctx, + `UPDATE tags SET updatedAt = ? WHERE userID = ? AND immichID = ?`, + updatedAt, userID, tagID, + ) + return err +} + +func (d *Database) replaceTagAssets(ctx context.Context, userID, tagID string, assetIDs []string) error { + tx, err := d.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + if err := bulkInsertTemp(ctx, tx, "tmpDesiredTagAssets", assetIDs); err != nil { + return fmt.Errorf("populate temp table: %w", err) + } + + if _, err := tx.ExecContext(ctx, + `DELETE FROM assetTags WHERE userID = ? AND tagID = ? AND assetID NOT IN (SELECT val FROM tmpDesiredTagAssets)`, + userID, tagID, + ); err != nil { + return fmt.Errorf("delete stale tag assets: %w", err) + } + + if _, err := tx.ExecContext(ctx, + `INSERT INTO assetTags (userID, tagID, assetID) + SELECT ?, ?, val FROM tmpDesiredTagAssets + WHERE val NOT IN (SELECT assetID FROM assetTags WHERE userID = ? AND tagID = ?)`, + userID, tagID, userID, tagID, + ); err != nil { + return fmt.Errorf("insert new tag assets: %w", err) + } + + tx.ExecContext(ctx, "DROP TABLE IF EXISTS tmpDesiredTagAssets") + return tx.Commit() +} + +func (d *Database) deleteTagsNotIn(ctx context.Context, userID string, tagIDs []string) error { + if len(tagIDs) == 0 { + _, err := d.db.ExecContext(ctx, "DELETE FROM tags WHERE userID = ?", userID) + return err + } + + tx, err := d.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + if err := bulkInsertTemp(ctx, tx, "tmpKeepTags", tagIDs); err != nil { + return fmt.Errorf("populate temp table: %w", err) + } + + if _, err := tx.ExecContext(ctx, "DELETE FROM tags WHERE userID = ? AND immichID NOT IN (SELECT val FROM tmpKeepTags)", userID); err != nil { + return fmt.Errorf("delete stale tags: %w", err) + } + + tx.ExecContext(ctx, "DROP TABLE IF EXISTS tmpKeepTags") + return tx.Commit() +} + +func (d *Database) getTags(ctx context.Context, userID string) ([]TagRow, error) { + rows, err := d.db.QueryContext(ctx, + `SELECT t.immichID, t.name, t.value, t.parentID, t.color, COUNT(a.immichID) AS assetCount + FROM tags t + LEFT JOIN assetTags at ON at.userID = t.userID AND at.tagID = t.immichID + LEFT JOIN assets a ON a.userID = at.userID AND a.immichID = at.assetID + AND a.stackPrimaryAssetID IS NULL`+hiddenLibraryFilterAliased+` + WHERE t.userID = ? + GROUP BY t.immichID + ORDER BY t.value ASC`, + userID, + ) + if err != nil { + return nil, fmt.Errorf("failed to query tags: %w", err) + } + defer rows.Close() + + var tags []TagRow + for rows.Next() { + var t TagRow + if err := rows.Scan(&t.ImmichID, &t.Name, &t.Value, &t.ParentID, &t.Color, &t.AssetCount); err != nil { + return nil, fmt.Errorf("failed to scan tag: %w", err) + } + tags = append(tags, t) + } + return tags, rows.Err() +} diff --git a/backend/database_test.go b/backend/database_test.go index 03b2bac..f8faf55 100644 --- a/backend/database_test.go +++ b/backend/database_test.go @@ -54,7 +54,7 @@ func TestUpsertAndCountAssets(t *testing.T) { t.Errorf("expected 3 total, got %d", total) } - withGPS, err := db.countFilteredAssets(ctx, testUserID, "", true, "all", "", "") + withGPS, err := db.countFilteredAssets(ctx, testUserID, "", "", true, "all", "", "") if err != nil { t.Fatalf("countFilteredAssets: %v", err) } @@ -79,7 +79,7 @@ func TestUpsertOverwritesExisting(t *testing.T) { if total != 1 { t.Errorf("expected 1 after upsert, got %d", total) } - withGPS, err := db.countFilteredAssets(ctx, testUserID, "", true, "all", "", "") + withGPS, err := db.countFilteredAssets(ctx, testUserID, "", "", true, "all", "", "") if err != nil { t.Fatalf("countFilteredAssets: %v", err) } @@ -95,7 +95,7 @@ func TestUpdateAssetHidden(t *testing.T) { seedAsset(t, db, "a1", ptr(48.85), ptr(2.35), "2024-01-01T12:00:00Z") seedAsset(t, db, "a2", ptr(40.71), ptr(-74.0), "2024-01-02T12:00:00Z") - visible, err := db.countFilteredAssets(ctx, testUserID, "", true, "visible", "", "") + visible, err := db.countFilteredAssets(ctx, testUserID, "", "", true, "visible", "", "") if err != nil { t.Fatalf("countFilteredAssets visible: %v", err) } @@ -108,7 +108,7 @@ func TestUpdateAssetHidden(t *testing.T) { t.Fatalf("updateAssetHidden: %v", err) } - visible, err = db.countFilteredAssets(ctx, testUserID, "", true, "visible", "", "") + visible, err = db.countFilteredAssets(ctx, testUserID, "", "", true, "visible", "", "") if err != nil { t.Fatalf("countFilteredAssets visible: %v", err) } @@ -116,7 +116,7 @@ func TestUpdateAssetHidden(t *testing.T) { t.Errorf("expected 1 visible after hiding, got %d", visible) } - hidden, err := db.countFilteredAssets(ctx, testUserID, "", true, "hidden", "", "") + hidden, err := db.countFilteredAssets(ctx, testUserID, "", "", true, "hidden", "", "") if err != nil { t.Fatalf("countFilteredAssets hidden: %v", err) } @@ -124,7 +124,7 @@ func TestUpdateAssetHidden(t *testing.T) { t.Errorf("expected 1 hidden, got %d", hidden) } - all, err := db.countFilteredAssets(ctx, testUserID, "", true, "all", "", "") + all, err := db.countFilteredAssets(ctx, testUserID, "", "", true, "all", "", "") if err != nil { t.Fatalf("countFilteredAssets all: %v", err) } @@ -137,7 +137,7 @@ func TestUpdateAssetHidden(t *testing.T) { t.Fatalf("updateAssetHidden unhide: %v", err) } - visible, err = db.countFilteredAssets(ctx, testUserID, "", true, "visible", "", "") + visible, err = db.countFilteredAssets(ctx, testUserID, "", "", true, "visible", "", "") if err != nil { t.Fatalf("countFilteredAssets visible: %v", err) } @@ -162,7 +162,7 @@ func TestHiddenFilterDoesNotAffectMapMarkers(t *testing.T) { t.Fatalf("updateAssetHidden: %v", err) } - markers, err := db.getMapMarkers(ctx, testUserID, "", nil, maxMapMarkers) + markers, err := db.getMapMarkers(ctx, testUserID, "", "", "", "", nil, maxMapMarkers) if err != nil { t.Fatalf("getMapMarkers: %v", err) } @@ -181,7 +181,7 @@ func TestIsHiddenSurvivesUpsert(t *testing.T) { t.Fatalf("updateAssetHidden: %v", err) } - hidden, err := db.countFilteredAssets(ctx, testUserID, "", true, "hidden", "", "") + hidden, err := db.countFilteredAssets(ctx, testUserID, "", "", true, "hidden", "", "") if err != nil { t.Fatalf("countFilteredAssets hidden: %v", err) } @@ -200,7 +200,7 @@ func TestIsHiddenSurvivesUpsert(t *testing.T) { t.Fatalf("upsertAssets: %v", err) } - hidden, err = db.countFilteredAssets(ctx, testUserID, "", true, "hidden", "", "") + hidden, err = db.countFilteredAssets(ctx, testUserID, "", "", true, "hidden", "", "") if err != nil { t.Fatalf("countFilteredAssets hidden: %v", err) } @@ -217,7 +217,7 @@ func TestGetMapMarkersNoBounds(t *testing.T) { seedAsset(t, db, "a2", ptr(40.71), ptr(-74.0), "2024-01-02T12:00:00Z") seedAsset(t, db, "a3", nil, nil, "2024-01-03T12:00:00Z") - markers, err := db.getMapMarkers(ctx, testUserID, "", nil, maxMapMarkers) + markers, err := db.getMapMarkers(ctx, testUserID, "", "", "", "", nil, maxMapMarkers) if err != nil { t.Fatalf("getMapMarkers: %v", err) } @@ -233,7 +233,7 @@ func TestGetMapMarkersRespectsLimit(t *testing.T) { seedAsset(t, db, "a1", ptr(48.85), ptr(2.35), "2024-01-01T12:00:00Z") seedAsset(t, db, "a2", ptr(40.71), ptr(-74.0), "2024-01-02T12:00:00Z") - markers, err := db.getMapMarkers(ctx, testUserID, "", nil, 1) + markers, err := db.getMapMarkers(ctx, testUserID, "", "", "", "", nil, 1) if err != nil { t.Fatalf("getMapMarkers with limit: %v", err) } @@ -253,7 +253,7 @@ func TestCountMapMarkers(t *testing.T) { seedAsset(t, db, "a2", ptr(40.71), ptr(-74.0), "2024-01-02T12:00:00Z") seedAsset(t, db, "a3", nil, nil, "2024-01-03T12:00:00Z") - count, err := db.countMapMarkers(ctx, testUserID, "", nil) + count, err := db.countMapMarkers(ctx, testUserID, "", "", "", "", nil) if err != nil { t.Fatalf("countMapMarkers: %v", err) } @@ -270,7 +270,7 @@ func TestGetMapMarkersWithBounds(t *testing.T) { seedAsset(t, db, "nyc", ptr(40.71), ptr(-74.0), "2024-01-02T12:00:00Z") bounds := &TViewportBounds{North: 50, South: 45, East: 10, West: -5} - markers, err := db.getMapMarkers(ctx, testUserID, "", bounds, maxMapMarkers) + markers, err := db.getMapMarkers(ctx, testUserID, "", "", "", "", bounds, maxMapMarkers) if err != nil { t.Fatalf("getMapMarkers with bounds: %v", err) } @@ -291,7 +291,7 @@ func TestGetMapMarkersDatelineCrossing(t *testing.T) { seedAsset(t, db, "paris", ptr(48.85), ptr(2.35), "2024-01-03T12:00:00Z") bounds := &TViewportBounds{North: -10, South: -20, East: -170, West: 170} - markers, err := db.getMapMarkers(ctx, testUserID, "", bounds, maxMapMarkers) + markers, err := db.getMapMarkers(ctx, testUserID, "", "", "", "", bounds, maxMapMarkers) if err != nil { t.Fatalf("getMapMarkers dateline: %v", err) } @@ -320,7 +320,7 @@ func TestBulkUpdateAssetLocation(t *testing.T) { t.Fatalf("bulkUpdateAssetLocation: %v", err) } - withGPS, err := db.countFilteredAssets(ctx, testUserID, "", true, "all", "", "") + withGPS, err := db.countFilteredAssets(ctx, testUserID, "", "", true, "all", "", "") if err != nil { t.Fatalf("countFilteredAssets: %v", err) } @@ -335,7 +335,7 @@ func TestBulkUpdateAssetLocationNullIsland(t *testing.T) { seedAsset(t, db, "a1", ptr(48.85), ptr(2.35), "2024-01-01T12:00:00Z") - withGPS, err := db.countFilteredAssets(ctx, testUserID, "", true, "all", "", "") + withGPS, err := db.countFilteredAssets(ctx, testUserID, "", "", true, "all", "", "") if err != nil { t.Fatalf("countFilteredAssets before: %v", err) } @@ -348,7 +348,7 @@ func TestBulkUpdateAssetLocationNullIsland(t *testing.T) { t.Fatalf("bulkUpdateAssetLocation: %v", err) } - withGPS, err = db.countFilteredAssets(ctx, testUserID, "", true, "all", "", "") + withGPS, err = db.countFilteredAssets(ctx, testUserID, "", "", true, "all", "", "") if err != nil { t.Fatalf("countFilteredAssets after: %v", err) } @@ -370,7 +370,7 @@ func TestBulkUpdateAssetHidden(t *testing.T) { t.Fatalf("bulkUpdateAssetHidden: %v", err) } - hidden, err := db.countFilteredAssets(ctx, testUserID, "", true, "hidden", "", "") + hidden, err := db.countFilteredAssets(ctx, testUserID, "", "", true, "hidden", "", "") if err != nil { t.Fatalf("countFilteredAssets hidden: %v", err) } @@ -378,7 +378,7 @@ func TestBulkUpdateAssetHidden(t *testing.T) { t.Errorf("expected 2 hidden after bulk hide, got %d", hidden) } - visible, err := db.countFilteredAssets(ctx, testUserID, "", true, "visible", "", "") + visible, err := db.countFilteredAssets(ctx, testUserID, "", "", true, "visible", "", "") if err != nil { t.Fatalf("countFilteredAssets visible: %v", err) } @@ -391,7 +391,7 @@ func TestBulkUpdateAssetHidden(t *testing.T) { t.Fatalf("bulkUpdateAssetHidden unhide: %v", err) } - visible, err = db.countFilteredAssets(ctx, testUserID, "", true, "visible", "", "") + visible, err = db.countFilteredAssets(ctx, testUserID, "", "", true, "visible", "", "") if err != nil { t.Fatalf("countFilteredAssets visible after unhide: %v", err) } @@ -408,7 +408,7 @@ func TestBulkUpdateAssetHidden(t *testing.T) { func TestGetAssetPageInfoNotFound(t *testing.T) { db := newTestDB(t) - _, err := db.getAssetPageInfo(context.Background(), testUserID, "nonexistent", "", 20) + _, err := db.getAssetPageInfo(context.Background(), testUserID, "nonexistent", "", "", 20) if err == nil { t.Fatal("expected error for nonexistent asset") } @@ -422,7 +422,7 @@ func TestGetAssetPageInfoFound(t *testing.T) { seedAsset(t, db, "a2", ptr(40.71), ptr(-74.0), "2024-01-02T12:00:00Z") seedAsset(t, db, "a3", ptr(35.68), ptr(139.69), "2024-01-03T12:00:00Z") - info, err := db.getAssetPageInfo(ctx, testUserID, "a2", "", 2) + info, err := db.getAssetPageInfo(ctx, testUserID, "a2", "", "", 2) if err != nil { t.Fatalf("getAssetPageInfo: %v", err) } @@ -786,7 +786,7 @@ func TestHiddenFilterAlbumPath(t *testing.T) { t.Fatalf("updateAssetHidden: %v", err) } - visible, err := db.countFilteredAssets(ctx, testUserID, "album1", true, "visible", "", "") + visible, err := db.countFilteredAssets(ctx, testUserID, "album1", "", true, "visible", "", "") if err != nil { t.Fatalf("countFilteredAssets visible: %v", err) } @@ -794,7 +794,7 @@ func TestHiddenFilterAlbumPath(t *testing.T) { t.Errorf("expected 1 visible in album, got %d", visible) } - hidden, err := db.countFilteredAssets(ctx, testUserID, "album1", true, "hidden", "", "") + hidden, err := db.countFilteredAssets(ctx, testUserID, "album1", "", true, "hidden", "", "") if err != nil { t.Fatalf("countFilteredAssets hidden: %v", err) } @@ -802,7 +802,7 @@ func TestHiddenFilterAlbumPath(t *testing.T) { t.Errorf("expected 1 hidden in album, got %d", hidden) } - all, err := db.countFilteredAssets(ctx, testUserID, "album1", true, "all", "", "") + all, err := db.countFilteredAssets(ctx, testUserID, "album1", "", true, "all", "", "") if err != nil { t.Fatalf("countFilteredAssets all: %v", err) } @@ -810,7 +810,7 @@ func TestHiddenFilterAlbumPath(t *testing.T) { t.Errorf("expected 2 total in album, got %d", all) } - assets, err := db.getFilteredAssets(ctx, testUserID, "album1", true, "visible", "", "", 1, 10) + assets, err := db.getFilteredAssets(ctx, testUserID, "album1", "", true, "visible", "", "", 1, 10) if err != nil { t.Fatalf("getFilteredAssets visible: %v", err) } @@ -818,7 +818,7 @@ func TestHiddenFilterAlbumPath(t *testing.T) { t.Errorf("expected 1 visible asset in album, got %d", len(assets)) } - assets, err = db.getFilteredAssets(ctx, testUserID, "album1", true, "hidden", "", "", 1, 10) + assets, err = db.getFilteredAssets(ctx, testUserID, "album1", "", true, "hidden", "", "", 1, 10) if err != nil { t.Fatalf("getFilteredAssets hidden: %v", err) } @@ -836,7 +836,7 @@ func TestBuildAssetFilterAlbumNoGPS(t *testing.T) { db.upsertAlbum(ctx, testUserID, "album1", "Test", nil, 2, "2024-01-01T00:00:00Z", nil) db.replaceAlbumAssets(ctx, testUserID, "album1", []string{"a1", "a2"}) - assets, err := db.getFilteredAssets(ctx, testUserID, "album1", false, "all", "", "", 1, 10) + assets, err := db.getFilteredAssets(ctx, testUserID, "album1", "", false, "all", "", "", 1, 10) if err != nil { t.Fatalf("getFilteredAssets album no GPS: %v", err) } @@ -844,7 +844,7 @@ func TestBuildAssetFilterAlbumNoGPS(t *testing.T) { t.Errorf("expected 1 no-GPS asset in album, got %d", len(assets)) } - count, err := db.countFilteredAssets(ctx, testUserID, "album1", false, "all", "", "") + count, err := db.countFilteredAssets(ctx, testUserID, "album1", "", false, "all", "", "") if err != nil { t.Fatalf("countFilteredAssets: %v", err) } @@ -862,7 +862,7 @@ func TestBuildAssetFilterAlbumWithGPS(t *testing.T) { db.upsertAlbum(ctx, testUserID, "album1", "Test", nil, 2, "2024-01-01T00:00:00Z", nil) db.replaceAlbumAssets(ctx, testUserID, "album1", []string{"a1", "a2"}) - assets, err := db.getFilteredAssets(ctx, testUserID, "album1", true, "all", "", "", 1, 10) + assets, err := db.getFilteredAssets(ctx, testUserID, "album1", "", true, "all", "", "", 1, 10) if err != nil { t.Fatalf("getFilteredAssets album with GPS: %v", err) } @@ -882,7 +882,7 @@ func TestGetAssetPageInfoWithAlbum(t *testing.T) { db.upsertAlbum(ctx, testUserID, "album1", "Test", nil, 3, "2024-01-01T00:00:00Z", nil) db.replaceAlbumAssets(ctx, testUserID, "album1", []string{"a1", "a2", "a3"}) - info, err := db.getAssetPageInfo(ctx, testUserID, "a2", "album1", 2) + info, err := db.getAssetPageInfo(ctx, testUserID, "a2", "album1", "", 2) if err != nil { t.Fatalf("getAssetPageInfo with album: %v", err) } @@ -902,7 +902,7 @@ func TestGetAssetPageInfoAlbumFallback(t *testing.T) { db.upsertAlbum(ctx, testUserID, "album1", "Test", nil, 1, "2024-01-01T00:00:00Z", nil) db.replaceAlbumAssets(ctx, testUserID, "album1", []string{"a1"}) - info, err := db.getAssetPageInfo(ctx, testUserID, "a1", "wrong-album", 20) + info, err := db.getAssetPageInfo(ctx, testUserID, "a1", "wrong-album", "", 20) if err != nil { t.Fatalf("getAssetPageInfo wrong album: %v", err) } @@ -920,7 +920,7 @@ func TestGetMapMarkersWithAlbum(t *testing.T) { db.upsertAlbum(ctx, testUserID, "album1", "Test", nil, 1, "2024-01-01T00:00:00Z", nil) db.replaceAlbumAssets(ctx, testUserID, "album1", []string{"a1"}) - markers, err := db.getMapMarkers(ctx, testUserID, "album1", nil, maxMapMarkers) + markers, err := db.getMapMarkers(ctx, testUserID, "album1", "", "", "", nil, maxMapMarkers) if err != nil { t.Fatalf("getMapMarkers with album: %v", err) } @@ -939,7 +939,7 @@ func TestGetMapMarkersWithAlbumAndBounds(t *testing.T) { db.replaceAlbumAssets(ctx, testUserID, "album1", []string{"a1", "a2"}) bounds := &TViewportBounds{North: 50, South: 45, East: 10, West: -5} - markers, err := db.getMapMarkers(ctx, testUserID, "album1", bounds, maxMapMarkers) + markers, err := db.getMapMarkers(ctx, testUserID, "album1", "", "", "", bounds, maxMapMarkers) if err != nil { t.Fatalf("getMapMarkers album+bounds: %v", err) } @@ -948,6 +948,119 @@ func TestGetMapMarkersWithAlbumAndBounds(t *testing.T) { } } +func TestFilteredAssetsByTag(t *testing.T) { + db := newTestDB(t) + ctx := context.Background() + + seedAsset(t, db, "a1", ptr(48.85), ptr(2.35), "2024-01-01T12:00:00Z") + seedAsset(t, db, "a2", nil, nil, "2024-01-02T12:00:00Z") + seedAsset(t, db, "a3", ptr(40.71), ptr(-74.0), "2024-01-03T12:00:00Z") + + if err := db.upsertTag(ctx, testUserID, "tag1", "Vacation", "Vacation", nil, nil); err != nil { + t.Fatalf("upsertTag: %v", err) + } + if err := db.replaceTagAssets(ctx, testUserID, "tag1", []string{"a1", "a2"}); err != nil { + t.Fatalf("replaceTagAssets: %v", err) + } + + noGPSTagged, err := db.countFilteredAssets(ctx, testUserID, "", "tag1", false, "all", "", "") + if err != nil { + t.Fatalf("countFilteredAssets tag1 no-gps: %v", err) + } + if noGPSTagged != 1 { + t.Errorf("expected 1 no-gps tagged (a2), got %d", noGPSTagged) + } + + withGPSTagged, err := db.countFilteredAssets(ctx, testUserID, "", "tag1", true, "all", "", "") + if err != nil { + t.Fatalf("countFilteredAssets tag1 with-gps: %v", err) + } + if withGPSTagged != 1 { + t.Errorf("expected 1 with-gps tagged (a1), got %d", withGPSTagged) + } + + pageWithGPS, err := db.getFilteredAssets(ctx, testUserID, "", "tag1", true, "all", "", "", 1, 10) + if err != nil { + t.Fatalf("getFilteredAssets tag1: %v", err) + } + if len(pageWithGPS) != 1 || pageWithGPS[0].ImmichID != "a1" { + t.Errorf("expected only a1 in tagged with-gps page, got %v", pageWithGPS) + } + + markersTagged, err := db.getMapMarkers(ctx, testUserID, "", "tag1", "", "", nil, maxMapMarkers) + if err != nil { + t.Fatalf("getMapMarkers tag1: %v", err) + } + if len(markersTagged) != 1 || markersTagged[0].ImmichID != "a1" { + t.Errorf("expected only a1 marker (only tagged + has gps), got %v", markersTagged) + } + + if err := db.deleteTagsNotIn(ctx, testUserID, []string{}); err != nil { + t.Fatalf("deleteTagsNotIn: %v", err) + } + staleCount, err := db.countFilteredAssets(ctx, testUserID, "", "tag1", true, "all", "", "") + if err != nil { + t.Fatalf("countFilteredAssets after delete: %v", err) + } + if staleCount != 0 { + t.Errorf("expected 0 after tag delete cascade, got %d", staleCount) + } +} + +func TestGetTagsCountExcludesOrphans(t *testing.T) { + db := newTestDB(t) + ctx := context.Background() + + seedAsset(t, db, "a1", ptr(48.85), ptr(2.35), "2024-01-01T12:00:00Z") + seedAsset(t, db, "a2", nil, nil, "2024-01-02T12:00:00Z") + + if err := db.upsertTag(ctx, testUserID, "tag1", "Vacation", "Vacation", nil, nil); err != nil { + t.Fatalf("upsertTag: %v", err) + } + // a1 and a2 are real; a-ghost references an asset that does not exist (orphan row). + if err := db.replaceTagAssets(ctx, testUserID, "tag1", []string{"a1", "a2", "a-ghost"}); err != nil { + t.Fatalf("replaceTagAssets: %v", err) + } + + tags, err := db.getTags(ctx, testUserID) + if err != nil { + t.Fatalf("getTags: %v", err) + } + if len(tags) != 1 { + t.Fatalf("expected 1 tag, got %d", len(tags)) + } + if tags[0].AssetCount != 2 { + t.Errorf("expected count 2 (orphaned a-ghost excluded), got %d", tags[0].AssetCount) + } +} + +func TestUpsertTagDefersUpdatedAt(t *testing.T) { + db := newTestDB(t) + ctx := context.Background() + + if err := db.upsertTag(ctx, testUserID, "tag1", "Vacation", "Vacation", nil, nil); err != nil { + t.Fatalf("upsertTag: %v", err) + } + m, err := db.getTagUpdatedAtMap(ctx, testUserID) + if err != nil { + t.Fatalf("getTagUpdatedAtMap: %v", err) + } + if m["tag1"] != "" { + t.Errorf("expected unsynced tag to have empty updatedAt, got %q", m["tag1"]) + } + + if err := db.setTagSynced(ctx, testUserID, "tag1", "2024-06-01T00:00:00Z"); err != nil { + t.Fatalf("setTagSynced: %v", err) + } + m, err = db.getTagUpdatedAtMap(ctx, testUserID) + if err != nil { + t.Fatalf("getTagUpdatedAtMap after sync: %v", err) + } + if m["tag1"] != "2024-06-01T00:00:00Z" { + t.Errorf("expected stamped updatedAt, got %q", m["tag1"]) + } +} + func TestDeleteAssetsNotIn(t *testing.T) { db := newTestDB(t) ctx := context.Background() diff --git a/backend/handlers.go b/backend/handlers.go index 666a121..27a2290 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -186,6 +186,7 @@ func (h *Handlers) handleGetAssets(w http.ResponseWriter, r *http.Request) { return } albumID := r.URL.Query().Get("albumID") + tagID := r.URL.Query().Get("tagID") withGPS := r.URL.Query().Get("gpsFilter") == "with-gps" hiddenFilter := r.URL.Query().Get("hiddenFilter") if hiddenFilter == "" { @@ -220,13 +221,13 @@ func (h *Handlers) handleGetAssets(w http.ResponseWriter, r *http.Request) { return } - assets, err := h.db.getFilteredAssets(ctx, user.ID, albumID, withGPS, hiddenFilter, startDate, endDate, page, pageSize) + assets, err := h.db.getFilteredAssets(ctx, user.ID, albumID, tagID, withGPS, hiddenFilter, startDate, endDate, page, pageSize) if err != nil { writeError(w, http.StatusInternalServerError, "failed to query assets") return } - total, err := h.db.countFilteredAssets(ctx, user.ID, albumID, withGPS, hiddenFilter, startDate, endDate) + total, err := h.db.countFilteredAssets(ctx, user.ID, albumID, tagID, withGPS, hiddenFilter, startDate, endDate) if err != nil { writeError(w, http.StatusInternalServerError, "failed to count assets") return @@ -254,6 +255,7 @@ func (h *Handlers) handleGetAssetDayCounts(w http.ResponseWriter, r *http.Reques } albumID := r.URL.Query().Get("albumID") + tagID := r.URL.Query().Get("tagID") withGPS := r.URL.Query().Get("gpsFilter") == "with-gps" hiddenFilter := r.URL.Query().Get("hiddenFilter") if hiddenFilter == "" { @@ -275,7 +277,7 @@ func (h *Handlers) handleGetAssetDayCounts(w http.ResponseWriter, r *http.Reques return } - counts, err := h.db.countAssetsByDay(ctx, user.ID, albumID, withGPS, hiddenFilter, startDate, endDate) + counts, err := h.db.countAssetsByDay(ctx, user.ID, albumID, tagID, withGPS, hiddenFilter, startDate, endDate) if err != nil { writeError(w, http.StatusInternalServerError, "failed to count assets by day") return @@ -329,6 +331,27 @@ func (h *Handlers) handleGetAlbums(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, albums) } +func (h *Handlers) handleGetTags(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := getUserFromContext(r) + if user == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + tags, err := h.db.getTags(ctx, user.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to query tags") + return + } + + if tags == nil { + tags = []TagRow{} + } + + writeJSON(w, http.StatusOK, tags) +} + func (h *Handlers) handleGetMapMarkers(w http.ResponseWriter, r *http.Request) { ctx := r.Context() user := getUserFromContext(r) @@ -337,6 +360,21 @@ func (h *Handlers) handleGetMapMarkers(w http.ResponseWriter, r *http.Request) { return } albumID := r.URL.Query().Get("albumID") + tagID := r.URL.Query().Get("tagID") + startDate := r.URL.Query().Get("startDate") + endDate := r.URL.Query().Get("endDate") + if startDate != "" { + if _, err := time.Parse("2006-01-02", startDate); err != nil { + writeError(w, http.StatusBadRequest, "startDate must be a valid date (YYYY-MM-DD)") + return + } + } + if endDate != "" { + if _, err := time.Parse("2006-01-02", endDate); err != nil { + writeError(w, http.StatusBadRequest, "endDate must be a valid date (YYYY-MM-DD)") + return + } + } limit, err := queryInt(r, "limit", defaultMapMarkersLimit) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) @@ -379,7 +417,7 @@ func (h *Handlers) handleGetMapMarkers(w http.ResponseWriter, r *http.Request) { bounds = &TViewportBounds{North: n, South: s, East: boundsReq.East, West: boundsReq.West} } - markers, err := h.db.getMapMarkers(ctx, user.ID, albumID, bounds, limit) + markers, err := h.db.getMapMarkers(ctx, user.ID, albumID, tagID, startDate, endDate, bounds, limit) if err != nil { writeError(w, http.StatusInternalServerError, "failed to query map markers") return @@ -763,13 +801,14 @@ func (h *Handlers) handleGetAssetPageInfo(w http.ResponseWriter, r *http.Request return } albumID := r.URL.Query().Get("albumID") + tagID := r.URL.Query().Get("tagID") if pageSize < 1 || pageSize > maxPageInfoPageSize { writeError(w, http.StatusBadRequest, fmt.Sprintf("pageSize must be between 1 and %d", maxPageInfoPageSize)) return } - info, err := h.db.getAssetPageInfo(ctx, user.ID, assetID, albumID, pageSize) + info, err := h.db.getAssetPageInfo(ctx, user.ID, assetID, albumID, tagID, pageSize) if err != nil { if errors.Is(err, sql.ErrNoRows) { writeError(w, http.StatusNotFound, "asset not found") diff --git a/backend/handlersAuth.go b/backend/handlersAuth.go index b287d21..dbcafab 100644 --- a/backend/handlersAuth.go +++ b/backend/handlersAuth.go @@ -141,7 +141,7 @@ func (h *AuthHandlers) handleRegister(w http.ResponseWriter, r *http.Request) { return } - markerCount, err := h.db.countMapMarkers(r.Context(), userID, "", nil) + markerCount, err := h.db.countMapMarkers(r.Context(), userID, "", "", "", "", nil) if err != nil { log.Printf("[Auth] Register failed to count map markers for user %s: %v", userID, err) } @@ -210,7 +210,7 @@ func buildMeResponse(ctx context.Context, db *Database, user *UserRow) (TMeRespo if err != nil { return TMeResponse{}, fmt.Errorf("get hasLibraryAccess for user %s: %w", user.ID, err) } - markerCount, err := db.countMapMarkers(ctx, user.ID, "", nil) + markerCount, err := db.countMapMarkers(ctx, user.ID, "", "", "", "", nil) if err != nil { return TMeResponse{}, fmt.Errorf("count map markers for user %s: %w", user.ID, err) } diff --git a/backend/handlers_test.go b/backend/handlers_test.go index f7a7948..00f58dc 100644 --- a/backend/handlers_test.go +++ b/backend/handlers_test.go @@ -39,6 +39,7 @@ func newTestHandlers(t *testing.T) (*Handlers, *http.ServeMux) { mux.HandleFunc("GET /health", handlers.handleHealth) mux.HandleFunc("GET /assets", handlers.handleGetAssets) mux.HandleFunc("GET /albums", handlers.handleGetAlbums) + mux.HandleFunc("GET /tags", handlers.handleGetTags) mux.HandleFunc("GET /map-markers", handlers.handleGetMapMarkers) mux.HandleFunc("PUT /assets/{assetID}/location", handlers.handleUpdateLocation) mux.HandleFunc("PUT /assets/{assetID}/hidden", handlers.handleUpdateHidden) @@ -73,6 +74,44 @@ func TestHealthEndpoint(t *testing.T) { } } +func TestHandleGetTags(t *testing.T) { + handlers, mux := newTestHandlers(t) + db := handlers.db.(*Database) + ctx := context.Background() + + seedAsset(t, db, "a1", ptr(48.85), ptr(2.35), "2024-01-01T12:00:00Z") + seedAsset(t, db, "a2", nil, nil, "2024-01-02T12:00:00Z") + if err := db.upsertTag(ctx, testUserID, "tag1", "Vacation", "Vacation", nil, nil); err != nil { + t.Fatalf("upsertTag: %v", err) + } + if err := db.replaceTagAssets(ctx, testUserID, "tag1", []string{"a1", "a2"}); err != nil { + t.Fatalf("replaceTagAssets: %v", err) + } + + req := withTestUser(httptest.NewRequest("GET", "/tags", nil)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + var tags []TagRow + if err := json.NewDecoder(rec.Body).Decode(&tags); err != nil { + t.Fatalf("decode tags: %v", err) + } + if len(tags) != 1 { + t.Fatalf("expected 1 tag, got %d", len(tags)) + } + if tags[0].ImmichID != "tag1" || tags[0].AssetCount != 2 { + t.Errorf("unexpected tag payload: %+v", tags[0]) + } + + unauthRec := httptest.NewRecorder() + mux.ServeHTTP(unauthRec, httptest.NewRequest("GET", "/tags", nil)) + if unauthRec.Code != http.StatusUnauthorized { + t.Errorf("expected 401 without auth, got %d", unauthRec.Code) + } +} + func TestAssetsEndpointReturnsEmptyList(t *testing.T) { _, mux := newTestHandlers(t) @@ -348,6 +387,7 @@ func newTestHandlersWithMockImmich(t *testing.T, immichHandler http.HandlerFunc) mux.HandleFunc("GET /health", handlers.handleHealth) mux.HandleFunc("GET /assets", handlers.handleGetAssets) mux.HandleFunc("GET /albums", handlers.handleGetAlbums) + mux.HandleFunc("GET /tags", handlers.handleGetTags) mux.HandleFunc("GET /map-markers", handlers.handleGetMapMarkers) mux.HandleFunc("PUT /assets/{assetID}/location", handlers.handleUpdateLocation) mux.HandleFunc("PUT /assets/{assetID}/hidden", handlers.handleUpdateHidden) @@ -857,7 +897,7 @@ func TestHandleUpdateHiddenSuccess(t *testing.T) { } ctx := context.Background() - hidden, err := d.countFilteredAssets(ctx, testUserID, "", true, "hidden", "", "") + hidden, err := d.countFilteredAssets(ctx, testUserID, "", "", true, "hidden", "", "") if err != nil { t.Fatalf("countFilteredAssets: %v", err) } @@ -926,7 +966,7 @@ func TestHandleBulkUpdateHiddenSuccess(t *testing.T) { } ctx := context.Background() - hidden, err := d.countFilteredAssets(ctx, testUserID, "", true, "hidden", "", "") + hidden, err := d.countFilteredAssets(ctx, testUserID, "", "", true, "hidden", "", "") if err != nil { t.Fatalf("countFilteredAssets: %v", err) } diff --git a/backend/immichClient.go b/backend/immichClient.go index d9f13ad..cdfd032 100644 --- a/backend/immichClient.go +++ b/backend/immichClient.go @@ -219,6 +219,69 @@ func (c *ImmichClient) getLibraries(ctx context.Context) ([]ImmichLibraryRespons return libraries, nil } +func (c *ImmichClient) getTags(ctx context.Context) ([]ImmichTagResponse, error) { + resp, err := c.doRequest(ctx, "GET", "/api/tags", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + io.Copy(io.Discard, resp.Body) + return nil, fmt.Errorf("immich getTags returned HTTP %d", resp.StatusCode) + } + + var tags []ImmichTagResponse + if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil { + return nil, fmt.Errorf("failed to decode tags response: %w", err) + } + return tags, nil +} + +func (c *ImmichClient) getTagAssetIDs(ctx context.Context, tagID string) ([]string, error) { + const tagSearchPageSize = 1000 + const tagSearchMaxPages = 1000 + + payload := map[string]interface{}{ + "tagIds": []string{tagID}, + "type": "IMAGE", + "visibility": "timeline", + "size": tagSearchPageSize, + "page": 1, + } + + var ids []string + for page := 1; page <= tagSearchMaxPages; page++ { + payload["page"] = page + resp, err := c.doRequest(ctx, "POST", "/api/search/metadata", payload) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("immich tag search returned HTTP %d", resp.StatusCode) + } + + var result ImmichSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + resp.Body.Close() + return nil, fmt.Errorf("failed to decode tag search response: %w", err) + } + resp.Body.Close() + + for _, item := range result.Assets.Items { + ids = append(ids, item.ID) + } + + if result.Assets.NextPage == nil { + return ids, nil + } + } + return nil, fmt.Errorf("tag %s asset list exceeded %d pages of %d", tagID, tagSearchMaxPages, tagSearchPageSize) +} + func (c *ImmichClient) getAlbumAssetIDs(ctx context.Context, albumID string) ([]string, error) { resp, err := c.doRequest(ctx, "GET", "/api/albums/"+albumID+"?withoutAssets=false", nil) if err != nil { diff --git a/backend/interfaces.go b/backend/interfaces.go index dcc33c7..98bc89b 100644 --- a/backend/interfaces.go +++ b/backend/interfaces.go @@ -16,29 +16,35 @@ type SyncStore interface { replaceFrequentLocations(ctx context.Context, userID string, locations []FrequentLocationRow) error getAlbumUpdatedAtMap(ctx context.Context, userID string) (map[string]string, error) upsertAlbum(ctx context.Context, userID, albumID, albumName string, thumbnailAssetID *string, assetCount int, updatedAt string, startDate *string) error + setAlbumSynced(ctx context.Context, userID, albumID, updatedAt string) error replaceAlbumAssets(ctx context.Context, userID, albumID string, assetIDs []string) error deleteAlbumsNotIn(ctx context.Context, userID string, albumIDs []string) error deleteAssetsNotIn(ctx context.Context, userID string, assetIDs []string) error upsertLibrary(ctx context.Context, libraryID, name string, assetCount int) error deleteLibrariesNotIn(ctx context.Context, libraryIDs []string) error needsLibraryIDBackfill(ctx context.Context, userID string) (bool, error) + getTagUpdatedAtMap(ctx context.Context, userID string) (map[string]string, error) + upsertTag(ctx context.Context, userID, tagID, name, value string, parentID, color *string) error + setTagSynced(ctx context.Context, userID, tagID, updatedAt string) error + replaceTagAssets(ctx context.Context, userID, tagID string, assetIDs []string) error + deleteTagsNotIn(ctx context.Context, userID string, tagIDs []string) error } type HandlerStore interface { getSyncState(ctx context.Context, userID, key string) (*string, error) countAssets(ctx context.Context, userID string) (int, error) countNoGPSAssets(ctx context.Context, userID string) (int, error) - getFilteredAssets(ctx context.Context, userID, albumID string, withGPS bool, hiddenFilter, startDate, endDate string, page, pageSize int) ([]AssetRow, error) - countFilteredAssets(ctx context.Context, userID, albumID string, withGPS bool, hiddenFilter, startDate, endDate string) (int, error) - countAssetsByDay(ctx context.Context, userID, albumID string, withGPS bool, hiddenFilter, startDate, endDate string) (map[string]int, error) + getFilteredAssets(ctx context.Context, userID, albumID, tagID string, withGPS bool, hiddenFilter, startDate, endDate string, page, pageSize int) ([]AssetRow, error) + countFilteredAssets(ctx context.Context, userID, albumID, tagID string, withGPS bool, hiddenFilter, startDate, endDate string) (int, error) + countAssetsByDay(ctx context.Context, userID, albumID, tagID string, withGPS bool, hiddenFilter, startDate, endDate string) (map[string]int, error) updateAssetHidden(ctx context.Context, userID, immichID string, isHidden bool) error bulkUpdateAssetHidden(ctx context.Context, userID string, immichIDs []string, isHidden bool) error - getMapMarkers(ctx context.Context, userID, albumID string, bounds *TViewportBounds, limit int) ([]MapMarker, error) + getMapMarkers(ctx context.Context, userID, albumID, tagID, startDate, endDate string, bounds *TViewportBounds, limit int) ([]MapMarker, error) getAssetStackID(ctx context.Context, userID, immichID string) (*string, error) getStackMemberIDs(ctx context.Context, userID, stackID string) ([]string, error) bulkUpdateAssetLocation(ctx context.Context, userID string, immichIDs []string, lat, lon float64) error getFrequentLocations(ctx context.Context, userID string, limit int) ([]FrequentLocationRow, error) - getAssetPageInfo(ctx context.Context, userID, assetID string, albumID string, pageSize int) (*AssetPageInfo, error) + getAssetPageInfo(ctx context.Context, userID, assetID string, albumID, tagID string, pageSize int) (*AssetPageInfo, error) getAssetByID(ctx context.Context, userID, immichID string) (*AssetRow, error) getAlbumsWithNoGPSCount(ctx context.Context, userID, startDate, endDate string) ([]AlbumRow, error) getAlbumsWithGPSCount(ctx context.Context, userID, startDate, endDate string) ([]AlbumRow, error) @@ -46,6 +52,7 @@ type HandlerStore interface { getFavoritePlaces(ctx context.Context, userID string) ([]FavoritePlaceRow, error) addFavoritePlace(ctx context.Context, userID string, latitude, longitude float64, displayName string) error removeFavoritePlace(ctx context.Context, userID string, latitude, longitude float64) error + getTags(ctx context.Context, userID string) ([]TagRow, error) } type SuggestionStore interface { @@ -68,6 +75,8 @@ type SyncImmichAPI interface { getAlbums(ctx context.Context) ([]ImmichAlbumResponse, error) getAlbumAssetIDs(ctx context.Context, albumID string) ([]string, error) getLibraries(ctx context.Context) ([]ImmichLibraryResponse, error) + getTags(ctx context.Context) ([]ImmichTagResponse, error) + getTagAssetIDs(ctx context.Context, tagID string) ([]string, error) } type HandlerLibraryStore interface { diff --git a/backend/main.go b/backend/main.go index 27589ba..11fe342 100644 --- a/backend/main.go +++ b/backend/main.go @@ -63,6 +63,7 @@ func main() { protectedMux := http.NewServeMux() protectedMux.HandleFunc("GET /albums", handlers.handleGetAlbums) + protectedMux.HandleFunc("GET /tags", handlers.handleGetTags) protectedMux.HandleFunc("GET /assets", handlers.handleGetAssets) protectedMux.HandleFunc("GET /assets/day-counts", handlers.handleGetAssetDayCounts) protectedMux.HandleFunc("GET /map-markers", handlers.handleGetMapMarkers) diff --git a/backend/migrations/016_add_tags.sql b/backend/migrations/016_add_tags.sql new file mode 100644 index 0000000..5be131f --- /dev/null +++ b/backend/migrations/016_add_tags.sql @@ -0,0 +1,26 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS tags ( + userID TEXT NOT NULL REFERENCES users(ID) ON DELETE CASCADE, + immichID TEXT NOT NULL, + name TEXT NOT NULL, + value TEXT NOT NULL, + parentID TEXT, + color TEXT, + updatedAt TEXT NOT NULL, + PRIMARY KEY (userID, immichID) +); + +CREATE TABLE IF NOT EXISTS assetTags ( + userID TEXT NOT NULL, + tagID TEXT NOT NULL, + assetID TEXT NOT NULL, + PRIMARY KEY (userID, tagID, assetID), + FOREIGN KEY (userID, tagID) REFERENCES tags(userID, immichID) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_asset_tags_asset ON assetTags(userID, assetID); + +-- +goose Down +DROP INDEX IF EXISTS idx_asset_tags_asset; +DROP TABLE IF EXISTS assetTags; +DROP TABLE IF EXISTS tags; diff --git a/backend/migrations_test.go b/backend/migrations_test.go index d594a5e..801f65d 100644 --- a/backend/migrations_test.go +++ b/backend/migrations_test.go @@ -113,8 +113,8 @@ func TestRunMigrationsBootstrapExistingDatabase(t *testing.T) { if err := db.QueryRow("SELECT version_id FROM goose_db_version WHERE is_applied = 1 ORDER BY id DESC LIMIT 1").Scan(&version); err != nil { t.Fatalf("query goose version: %v", err) } - if version != 15 { - t.Errorf("expected stamped version 15, got %d", version) + if version != 16 { + t.Errorf("expected stamped version 16, got %d", version) } if _, err := db.Exec("INSERT INTO users (ID, email, passwordHash) VALUES ('u1', 'test@example.com', 'hashed')"); err != nil { diff --git a/backend/syncService.go b/backend/syncService.go index 4c2d516..2c2223e 100644 --- a/backend/syncService.go +++ b/backend/syncService.go @@ -17,6 +17,7 @@ import ( const ( syncPageSize = 1000 albumFetchLimit = 5 + tagFetchLimit = 5 cancelSyncWaitTimeout = 5 * time.Second cancelSyncPollIntervalMS = 10 * time.Millisecond ) @@ -225,9 +226,10 @@ func (s *SyncService) doUserFullSync(ctx context.Context, userID string, immich } s.recomputeFrequentLocations(ctx, userID) albumErr := s.syncAlbums(ctx, userID, immich, true) + tagErr := s.syncTags(ctx, userID, immich, true) - if albumErr != nil { - s.recordSyncError(ctx, userID, fmt.Sprintf("full sync: %v", albumErr)) + if syncErr := errors.Join(albumErr, tagErr); syncErr != nil { + s.recordSyncError(ctx, userID, fmt.Sprintf("full sync: %v", syncErr)) } else { s.clearSyncError(ctx, userID) } @@ -293,9 +295,10 @@ func (s *SyncService) doUserIncrementalSync(ctx context.Context, userID string, } albumErr := s.syncAlbums(ctx, userID, immich, false) + tagErr := s.syncTags(ctx, userID, immich, false) - if albumErr != nil { - s.recordSyncError(ctx, userID, fmt.Sprintf("incremental sync: %v", albumErr)) + if syncErr := errors.Join(albumErr, tagErr); syncErr != nil { + s.recordSyncError(ctx, userID, fmt.Sprintf("incremental sync: %v", syncErr)) } else { s.clearSyncError(ctx, userID) } @@ -443,8 +446,9 @@ type albumWork struct { } type albumFetchResult struct { - albumID string - assetIDs []string + albumID string + assetIDs []string + updatedAt string } func (s *SyncService) syncAlbums(ctx context.Context, userID string, immich SyncImmichAPI, forceRefresh bool) error { @@ -483,7 +487,10 @@ func (s *SyncService) upsertAlbumMetadata(ctx context.Context, userID string, al for _, album := range albums { albumIDs = append(albumIDs, album.ID) - if err := s.db.upsertAlbum(ctx, userID, album.ID, album.AlbumName, album.AlbumThumbnailAssetID, album.AssetCount, album.UpdatedAt, album.StartDate); err != nil { + // Keep the stored updatedAt at its old value (empty for a new album) so the + // album stays "changed" until its assets are replaced; setAlbumSynced advances + // it only after a successful fetch. A failed fetch is then retried next sync. + if err := s.db.upsertAlbum(ctx, userID, album.ID, album.AlbumName, album.AlbumThumbnailAssetID, album.AssetCount, existing[album.ID], album.StartDate); err != nil { log.Printf("[Sync] Failed to upsert album %s for user %s: %v", album.ID, userID, err) continue } @@ -500,6 +507,7 @@ func (s *SyncService) fetchAndReplaceAlbumAssets(ctx context.Context, userID str g.SetLimit(albumFetchLimit) var fetchMu sync.Mutex var fetched []albumFetchResult + var fetchErrs []error for _, w := range work { if !w.changed { @@ -510,25 +518,31 @@ func (s *SyncService) fetchAndReplaceAlbumAssets(ctx context.Context, userID str fetchCtx, fetchCancel := context.WithTimeout(ctx, 30*time.Second) defer fetchCancel() assetIDs, err := immich.getAlbumAssetIDs(fetchCtx, album.ID) + fetchMu.Lock() + defer fetchMu.Unlock() if err != nil { - return fmt.Errorf("fetch asset IDs for album %s: %w", album.ID, err) + fetchErrs = append(fetchErrs, fmt.Errorf("fetch asset IDs for album %s: %w", album.ID, err)) + return nil } - fetchMu.Lock() - fetched = append(fetched, albumFetchResult{albumID: album.ID, assetIDs: assetIDs}) - fetchMu.Unlock() + fetched = append(fetched, albumFetchResult{albumID: album.ID, assetIDs: assetIDs, updatedAt: album.UpdatedAt}) return nil }) } - if err := g.Wait(); err != nil { - return fmt.Errorf("album asset fetch: %w", err) - } + _ = g.Wait() for _, result := range fetched { if err := s.db.replaceAlbumAssets(ctx, userID, result.albumID, result.assetIDs); err != nil { - return fmt.Errorf("replace album assets for %s: %w", result.albumID, err) + fetchErrs = append(fetchErrs, fmt.Errorf("replace album assets for %s: %w", result.albumID, err)) + continue + } + if err := s.db.setAlbumSynced(ctx, userID, result.albumID, result.updatedAt); err != nil { + fetchErrs = append(fetchErrs, fmt.Errorf("mark album %s synced for user %s: %w", result.albumID, userID, err)) } } + if len(fetchErrs) > 0 { + return errors.Join(fetchErrs...) + } return nil } diff --git a/backend/syncService_test.go b/backend/syncService_test.go index 81f787d..8545a72 100644 --- a/backend/syncService_test.go +++ b/backend/syncService_test.go @@ -202,6 +202,108 @@ func TestSyncAlbumsErrorPropagation(t *testing.T) { } } +func TestSyncAlbumsFailedFetchLeavesAlbumUnsynced(t *testing.T) { + ctx := context.Background() + var failAssets atomic.Bool + failAssets.Store(true) + + factory, immich := newMockImmichFactoryNoRetry(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/albums": + json.NewEncoder(w).Encode([]ImmichAlbumResponse{ + {ID: "album1", AlbumName: "Test", AssetCount: 1, UpdatedAt: "2024-06-01T00:00:00Z"}, + }) + case strings.HasPrefix(r.URL.Path, "/api/albums/"): + if failAssets.Load() { + w.WriteHeader(http.StatusInternalServerError) + return + } + json.NewEncoder(w).Encode(ImmichAlbumDetailResponse{}) + default: + http.NotFound(w, r) + } + }) + + db := newTestDB(t) + svc := newSyncService(db, factory, newNominatimClient(10*time.Second)) + + // Album asset fetch fails: the album must stay unsynced so it is re-detected next run. + if err := svc.syncAlbums(ctx, testUserID, immich, false); err == nil { + t.Fatal("expected error from failed album asset fetch") + } + m, err := db.getAlbumUpdatedAtMap(ctx, testUserID) + if err != nil { + t.Fatalf("getAlbumUpdatedAtMap: %v", err) + } + if m["album1"] != "" { + t.Errorf("expected album1 to stay unsynced (empty updatedAt), got %q", m["album1"]) + } + + // Immich recovers: the still-changed album is re-fetched and stamped. + failAssets.Store(false) + if err := svc.syncAlbums(ctx, testUserID, immich, false); err != nil { + t.Fatalf("syncAlbums after recovery: %v", err) + } + m, err = db.getAlbumUpdatedAtMap(ctx, testUserID) + if err != nil { + t.Fatalf("getAlbumUpdatedAtMap after recovery: %v", err) + } + if m["album1"] != "2024-06-01T00:00:00Z" { + t.Errorf("expected album1 stamped after recovery, got %q", m["album1"]) + } +} + +func TestSyncTagsFailedFetchLeavesTagUnsynced(t *testing.T) { + ctx := context.Background() + var failSearch atomic.Bool + failSearch.Store(true) + + factory, immich := newMockImmichFactoryNoRetry(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/tags": + json.NewEncoder(w).Encode([]ImmichTagResponse{ + {ID: "tag1", Name: "Vacation", Value: "Vacation", UpdatedAt: "2024-06-01T00:00:00Z"}, + }) + case "/api/search/metadata": + if failSearch.Load() { + w.WriteHeader(http.StatusInternalServerError) + return + } + json.NewEncoder(w).Encode(ImmichSearchResponse{}) + default: + http.NotFound(w, r) + } + }) + + db := newTestDB(t) + svc := newSyncService(db, factory, newNominatimClient(10*time.Second)) + + // Tag asset fetch fails: the tag must stay unsynced so it is re-detected next run. + if err := svc.syncTags(ctx, testUserID, immich, false); err == nil { + t.Fatal("expected error from failed tag asset fetch") + } + m, err := db.getTagUpdatedAtMap(ctx, testUserID) + if err != nil { + t.Fatalf("getTagUpdatedAtMap: %v", err) + } + if m["tag1"] != "" { + t.Errorf("expected tag1 to stay unsynced (empty updatedAt), got %q", m["tag1"]) + } + + // Immich recovers: the still-changed tag is re-fetched and stamped. + failSearch.Store(false) + if err := svc.syncTags(ctx, testUserID, immich, false); err != nil { + t.Fatalf("syncTags after recovery: %v", err) + } + m, err = db.getTagUpdatedAtMap(ctx, testUserID) + if err != nil { + t.Fatalf("getTagUpdatedAtMap after recovery: %v", err) + } + if m["tag1"] != "2024-06-01T00:00:00Z" { + t.Errorf("expected tag1 stamped after recovery, got %q", m["tag1"]) + } +} + func TestSyncStacksUpdatesDB(t *testing.T) { ctx := context.Background() @@ -1394,7 +1496,7 @@ func TestBatchUpsertAssets(t *testing.T) { t.Errorf("expected 3 assets, got %d", total) } - withGPS, err := db.countFilteredAssets(ctx, testUserID, "", true, "all", "", "") + withGPS, err := db.countFilteredAssets(ctx, testUserID, "", "", true, "all", "", "") if err != nil { t.Fatalf("countFilteredAssets: %v", err) } diff --git a/backend/syncTags.go b/backend/syncTags.go new file mode 100644 index 0000000..fef79c0 --- /dev/null +++ b/backend/syncTags.go @@ -0,0 +1,117 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "sync" + "time" + + "golang.org/x/sync/errgroup" +) + +type tagWork struct { + tag ImmichTagResponse + changed bool +} + +type tagFetchResult struct { + tagID string + assetIDs []string + updatedAt string +} + +func (s *SyncService) syncTags(ctx context.Context, userID string, immich SyncImmichAPI, forceRefresh bool) error { + log.Printf("[Sync] Syncing tags for user %s...", userID) + + listCtx, listCancel := context.WithTimeout(ctx, 30*time.Second) + defer listCancel() + tags, err := immich.getTags(listCtx) + if err != nil { + return fmt.Errorf("fetch tags: %w", err) + } + + existingTags, err := s.db.getTagUpdatedAtMap(ctx, userID) + if err != nil { + return fmt.Errorf("get tag updatedAt map: %w", err) + } + + tagIDs, changedWork := s.upsertTagMetadata(ctx, userID, tags, existingTags, forceRefresh) + + if err := s.fetchAndReplaceTagAssets(ctx, userID, changedWork, immich); err != nil { + return err + } + + if err := s.db.deleteTagsNotIn(ctx, userID, tagIDs); err != nil { + log.Printf("[Sync] Failed to clean up stale tags for user %s: %v", userID, err) + } + + log.Printf("[Sync] Tag sync completed for user %s: %d tags", userID, len(tags)) + return nil +} + +func (s *SyncService) upsertTagMetadata(ctx context.Context, userID string, tags []ImmichTagResponse, existing map[string]string, forceRefresh bool) ([]string, []tagWork) { + tagIDs := make([]string, 0, len(tags)) + work := make([]tagWork, 0, len(tags)) + + for _, tag := range tags { + tagIDs = append(tagIDs, tag.ID) + + if err := s.db.upsertTag(ctx, userID, tag.ID, tag.Name, tag.Value, tag.ParentID, tag.Color); err != nil { + log.Printf("[Sync] Failed to upsert tag %s for user %s: %v", tag.ID, userID, err) + continue + } + + changed := forceRefresh || existing[tag.ID] != tag.UpdatedAt + work = append(work, tagWork{tag: tag, changed: changed}) + } + + return tagIDs, work +} + +func (s *SyncService) fetchAndReplaceTagAssets(ctx context.Context, userID string, work []tagWork, immich SyncImmichAPI) error { + g := new(errgroup.Group) + g.SetLimit(tagFetchLimit) + var fetchMu sync.Mutex + var fetched []tagFetchResult + var fetchErrs []error + + for _, w := range work { + if !w.changed { + continue + } + tag := w.tag + g.Go(func() error { + fetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second) + defer fetchCancel() + assetIDs, err := immich.getTagAssetIDs(fetchCtx, tag.ID) + fetchMu.Lock() + defer fetchMu.Unlock() + if err != nil { + fetchErrs = append(fetchErrs, fmt.Errorf("fetch asset IDs for tag %s: %w", tag.ID, err)) + return nil + } + fetched = append(fetched, tagFetchResult{tagID: tag.ID, assetIDs: assetIDs, updatedAt: tag.UpdatedAt}) + return nil + }) + } + _ = g.Wait() + + for _, result := range fetched { + if err := s.db.replaceTagAssets(ctx, userID, result.tagID, result.assetIDs); err != nil { + fetchErrs = append(fetchErrs, fmt.Errorf("replace tag assets for %s: %w", result.tagID, err)) + continue + } + // Stamp updatedAt only after assets land, so a failed fetch leaves the tag + // marked unsynced and it is re-detected as changed on the next sync. + if err := s.db.setTagSynced(ctx, userID, result.tagID, result.updatedAt); err != nil { + fetchErrs = append(fetchErrs, fmt.Errorf("mark tag %s synced for user %s: %w", result.tagID, userID, err)) + } + } + + if len(fetchErrs) > 0 { + return errors.Join(fetchErrs...) + } + return nil +} diff --git a/backend/types.go b/backend/types.go index 9a8f77c..3a64aea 100644 --- a/backend/types.go +++ b/backend/types.go @@ -150,6 +150,24 @@ type ImmichAlbumDetailResponse struct { } `json:"assets"` } +type TagRow struct { + ImmichID string `json:"immichID"` + Name string `json:"name"` + Value string `json:"value"` + ParentID *string `json:"parentID"` + Color *string `json:"color"` + AssetCount int `json:"assetCount"` +} + +type ImmichTagResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Value string `json:"value"` + ParentID *string `json:"parentId"` + Color *string `json:"color"` + UpdatedAt string `json:"updatedAt"` +} + type AssetPageInfo struct { Page int `json:"page"` AlbumID *string `json:"albumID"` diff --git a/src/features/albums/useAlbums.ts b/src/features/albums/useAlbums.ts index a5af1c7..b1272f6 100644 --- a/src/features/albums/useAlbums.ts +++ b/src/features/albums/useAlbums.ts @@ -67,7 +67,6 @@ export function useAlbums( const prev = prevFilterRef.current; if (prev.gpsFilter !== gpsFilter || prev.startDate !== startDate || prev.endDate !== endDate) { prevFilterRef.current = {gpsFilter, startDate, endDate}; - setAlbums([]); void load(); } }, [gpsFilter, startDate, endDate, load]); diff --git a/src/features/filterBar/FilterBar.tsx b/src/features/filterBar/FilterBar.tsx index 4da97f4..0f53534 100644 --- a/src/features/filterBar/FilterBar.tsx +++ b/src/features/filterBar/FilterBar.tsx @@ -10,6 +10,7 @@ import {GPXStatusFilterGroup} from '@/features/filterBar/GPXStatusFilterGroup'; import {HeaderTitle} from '@/features/filterBar/HeaderTitle'; import {SettingsIcon} from '@/features/filterBar/SettingsIcon'; import {SettingsPanel} from '@/features/filterBar/SettingsPanel'; +import {TagFilterGroup} from '@/features/filterBar/TagFilterGroup'; import {useScrollHeight} from '@/features/filterBar/useScrollHeight'; import {ViewModeGroup} from '@/features/filterBar/ViewModeGroup'; import {cn} from '@/utils/cn'; @@ -37,6 +38,9 @@ type TFilterBarProps = { startDate: string | null; endDate: string | null; onDateRangeAction: (startDate: string | null, endDate: string | null) => void; + selectedTagID: string | null; + onTagAction: (tagID: string | null) => void; + tagFilterDisabled: boolean; isSyncing: boolean; syncError?: string | null; onSyncAction: () => Promise; @@ -67,6 +71,9 @@ export function FilterBar({ startDate, endDate, onDateRangeAction, + selectedTagID, + onTagAction, + tagFilterDisabled, isSyncing, syncError, onSyncAction, @@ -233,6 +240,11 @@ export function FilterBar({ endDate={endDate} onDateRangeAction={onDateRangeAction} /> + )} diff --git a/src/features/filterBar/TagFilterGroup.tsx b/src/features/filterBar/TagFilterGroup.tsx new file mode 100644 index 0000000..76a4fdf --- /dev/null +++ b/src/features/filterBar/TagFilterGroup.tsx @@ -0,0 +1,173 @@ +'use client'; + +import {TagIcon} from 'lucide-react'; +import {useMemo, useState} from 'react'; + +import {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover'; +import {useTags} from '@/features/tags/useTags'; +import {cn} from '@/utils/cn'; + +import type {TTagRow} from '@/shared/types/tag'; +import type {ReactElement} from 'react'; + +type TTagFilterGroupProps = { + selectedTagID: string | null; + onTagAction: (tagID: string | null) => void; + disabled?: boolean; +}; + +const buttonBase = + 'cursor-pointer rounded-md border px-2 py-1 text-[0.6875rem] font-medium transition-all duration-150'; +const buttonActive = 'border-(--color-primary) bg-(--color-selected) text-(--color-primary)'; +const buttonInactive = + 'border-(--color-border) bg-transparent text-(--color-text-secondary) hover:border-(--color-text-secondary)'; + +function resolveTriggerStyle(open: boolean, hasSelection: boolean): string { + if (open) { + return buttonActive; + } + if (hasSelection) { + return 'border-(--color-primary) bg-(--color-primary)/10 text-(--color-primary)'; + } + return buttonInactive; +} + +function formatTagLabel(tag: TTagRow | null): string { + if (!tag) { + return 'All tags'; + } + return tag.value; +} + +function matchesQuery(tag: TTagRow, query: string): boolean { + if (!query) { + return true; + } + return tag.value.toLowerCase().includes(query.toLowerCase()); +} + +export function TagFilterGroup({selectedTagID, onTagAction, disabled = false}: TTagFilterGroupProps): ReactElement { + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(''); + const {tags, isLoading, error} = useTags(isOpen); + + const selectedTag = useMemo(() => { + if (!selectedTagID) { + return null; + } + return tags.find(t => t.immichID === selectedTagID) ?? null; + }, [selectedTagID, tags]); + + const filteredTags = useMemo(() => tags.filter(t => matchesQuery(t, query)), [tags, query]); + + let triggerLabel = formatTagLabel(selectedTag); + if (selectedTagID && !selectedTag && !isLoading) { + triggerLabel = 'Tag'; + } + + let listBody: ReactElement; + if (isLoading) { + listBody =
{'Loading…'}
; + } else if (error) { + listBody =
{error}
; + } else if (tags.length === 0) { + listBody =
{'No tags found.'}
; + } else if (filteredTags.length === 0) { + listBody =
{'No matches.'}
; + } else { + listBody = ( + + ); + } + + return ( +
+
+
+ {'Tag'} +
+ {selectedTagID && !disabled && ( + + )} +
+ { + if (disabled) { + return; + } + setIsOpen(open); + if (!open) { + setQuery(''); + } + }}> + + + + +
+ setQuery(e.target.value)} + placeholder={'Search tags…'} + className={ + 'w-full rounded-md border border-(--color-border) bg-transparent px-2 py-1 text-[0.75rem] text-(--color-text) focus:border-(--color-primary) focus:outline-none' + } + /> +
+ {listBody} +
+
+
+ ); +} diff --git a/src/features/filterBar/useDayCounts.ts b/src/features/filterBar/useDayCounts.ts index b8ebe58..30bdda8 100644 --- a/src/features/filterBar/useDayCounts.ts +++ b/src/features/filterBar/useDayCounts.ts @@ -7,7 +7,7 @@ import {useView} from '@/shared/context/AppContext'; import {fetchAssetDayCounts} from '@/shared/services/backendApi'; export function useDayCounts(visibleMonth: Date, open: boolean): Record { - const {gpsFilter, hiddenFilter, selectedAlbumID} = useView(); + const {gpsFilter, hiddenFilter, selectedAlbumID, selectedTagID} = useView(); const [counts, setCounts] = useState>({}); const abortRef = useRef(null); @@ -22,6 +22,7 @@ export function useDayCounts(visibleMonth: Date, open: boolean): Record { diff --git a/src/features/filterBar/useURLState.ts b/src/features/filterBar/useURLState.ts index 089bfc1..a9b3d62 100644 --- a/src/features/filterBar/useURLState.ts +++ b/src/features/filterBar/useURLState.ts @@ -1,6 +1,6 @@ 'use client'; -import {useCallback, useEffect, useMemo, useState} from 'react'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {ALLOWED_GRID_COLUMNS, ALLOWED_PAGE_SIZES} from '@/features/filterBar/constant'; import { @@ -19,6 +19,7 @@ import { URL_PARAM_MARKER_LIMIT, URL_PARAM_PAGE_SIZE, URL_PARAM_START_DATE, + URL_PARAM_TAG_ID, URL_PARAM_VIEW_MODE, VIEW_MODE_DEFAULT, clampVisibleMarkerLimit @@ -32,6 +33,7 @@ type TURLSyncState = { hiddenFilter: THiddenFilter; viewMode: TViewMode; selectedAlbumID: string | null; + selectedTagID: string | null; pageSize: number; gridColumns: number; visibleMarkerLimit: number; @@ -54,6 +56,8 @@ type TURLState = { setViewModeAction: (mode: TViewMode) => void; selectedAlbumID: string | null; setSelectedAlbumIDAction: (albumID: string | null) => void; + selectedTagID: string | null; + setSelectedTagIDAction: (tagID: string | null) => void; startDate: string | null; setStartDateAction: (date: string | null) => void; endDate: string | null; @@ -175,6 +179,12 @@ function buildURLFromState(state: TURLSyncState): string { params.set(URL_PARAM_ALBUM_ID, sanitizedAlbumID); } } + if (state.selectedTagID) { + const sanitizedTagID = sanitizeAlbumID(state.selectedTagID); + if (sanitizedTagID) { + params.set(URL_PARAM_TAG_ID, sanitizedTagID); + } + } if (state.pageSize !== DEFAULT_PAGE_SIZE) { params.set(URL_PARAM_PAGE_SIZE, String(state.pageSize)); } @@ -202,6 +212,7 @@ type TURLStateSetters = { setHiddenFilter: (filter: THiddenFilter) => void; setViewMode: (mode: TViewMode) => void; setSelectedAlbumID: (albumID: string | null) => void; + setSelectedTagID: (tagID: string | null) => void; setPageSize: (size: number) => void; setGridColumns: (cols: number) => void; setVisibleMarkerLimit: (limit: number) => void; @@ -226,6 +237,7 @@ function applyURLToState(search: string, setters: TURLStateSetters): void { } setters.setSelectedAlbumID(sanitizeAlbumID(params.get(URL_PARAM_ALBUM_ID))); + setters.setSelectedTagID(sanitizeAlbumID(params.get(URL_PARAM_TAG_ID))); setters.setPageSize(normalizePageParam(params.get(URL_PARAM_PAGE_SIZE), DEFAULT_PAGE_SIZE, ALLOWED_PAGE_SIZES)); setters.setGridColumns( normalizePageParam(params.get(URL_PARAM_GRID_COLUMNS), DEFAULT_GRID_COLUMNS, ALLOWED_GRID_COLUMNS) @@ -237,43 +249,87 @@ function applyURLToState(search: string, setters: TURLStateSetters): void { export function useURLState(): TURLState { const urlSync = useMemo(() => createBrowserURLSync(), []); - const [gpsFilter, setGPSFilterRawAction] = useState(GPS_FILTER_DEFAULT); - const [hiddenFilter, setHiddenFilterRawAction] = useState(HIDDEN_FILTER_DEFAULT); - const [pageSize, setPageSizeAction] = useState(DEFAULT_PAGE_SIZE); - const [gridColumns, setGridColumnsAction] = useState(DEFAULT_GRID_COLUMNS); - const [visibleMarkerLimit, setVisibleMarkerLimitAction] = useState(DEFAULT_VISIBLE_MARKER_LIMIT); - const [viewMode, setViewModeAction] = useState(VIEW_MODE_DEFAULT); - const [selectedAlbumID, setSelectedAlbumIDAction] = useState(null); - const [startDate, setStartDateAction] = useState(null); - const [endDate, setEndDateAction] = useState(null); - - const buildURL = useCallback( - (state?: Partial) => { - const nextState: TURLSyncState = { - gpsFilter: state?.gpsFilter ?? gpsFilter, - hiddenFilter: state?.hiddenFilter ?? hiddenFilter, - viewMode: state?.viewMode ?? viewMode, - selectedAlbumID: resolveNullableOverride(state, 'selectedAlbumID', selectedAlbumID), - pageSize: state?.pageSize ?? pageSize, - gridColumns: state?.gridColumns ?? gridColumns, - visibleMarkerLimit: state?.visibleMarkerLimit ?? visibleMarkerLimit, - startDate: resolveNullableOverride(state, 'startDate', startDate), - endDate: resolveNullableOverride(state, 'endDate', endDate) - }; - return buildURLFromState(nextState); - }, - [ - gpsFilter, - hiddenFilter, - viewMode, - selectedAlbumID, - pageSize, - gridColumns, - visibleMarkerLimit, - startDate, - endDate - ] - ); + const [gpsFilter, setGPSFilterState] = useState(GPS_FILTER_DEFAULT); + const [hiddenFilter, setHiddenFilterState] = useState(HIDDEN_FILTER_DEFAULT); + const [pageSize, setPageSizeState] = useState(DEFAULT_PAGE_SIZE); + const [gridColumns, setGridColumnsState] = useState(DEFAULT_GRID_COLUMNS); + const [visibleMarkerLimit, setVisibleMarkerLimitState] = useState(DEFAULT_VISIBLE_MARKER_LIMIT); + const [viewMode, setViewModeState] = useState(VIEW_MODE_DEFAULT); + const [selectedAlbumID, setSelectedAlbumIDState] = useState(null); + const [selectedTagID, setSelectedTagIDState] = useState(null); + const [startDate, setStartDateState] = useState(null); + const [endDate, setEndDateState] = useState(null); + + const liveStateRef = useRef({ + gpsFilter: GPS_FILTER_DEFAULT, + hiddenFilter: HIDDEN_FILTER_DEFAULT, + viewMode: VIEW_MODE_DEFAULT, + selectedAlbumID: null, + selectedTagID: null, + pageSize: DEFAULT_PAGE_SIZE, + gridColumns: DEFAULT_GRID_COLUMNS, + visibleMarkerLimit: DEFAULT_VISIBLE_MARKER_LIMIT, + startDate: null, + endDate: null + }); + + const setGPSFilterRawAction = useCallback((value: TGPSFilter) => { + liveStateRef.current.gpsFilter = value; + setGPSFilterState(value); + }, []); + const setHiddenFilterRawAction = useCallback((value: THiddenFilter) => { + liveStateRef.current.hiddenFilter = value; + setHiddenFilterState(value); + }, []); + const setPageSizeAction = useCallback((value: number) => { + liveStateRef.current.pageSize = value; + setPageSizeState(value); + }, []); + const setGridColumnsAction = useCallback((value: number) => { + liveStateRef.current.gridColumns = value; + setGridColumnsState(value); + }, []); + const setVisibleMarkerLimitAction = useCallback((value: number) => { + liveStateRef.current.visibleMarkerLimit = value; + setVisibleMarkerLimitState(value); + }, []); + const setViewModeAction = useCallback((value: TViewMode) => { + liveStateRef.current.viewMode = value; + setViewModeState(value); + }, []); + const setSelectedAlbumIDAction = useCallback((value: string | null) => { + liveStateRef.current.selectedAlbumID = value; + setSelectedAlbumIDState(value); + }, []); + const setSelectedTagIDAction = useCallback((value: string | null) => { + liveStateRef.current.selectedTagID = value; + setSelectedTagIDState(value); + }, []); + const setStartDateAction = useCallback((value: string | null) => { + liveStateRef.current.startDate = value; + setStartDateState(value); + }, []); + const setEndDateAction = useCallback((value: string | null) => { + liveStateRef.current.endDate = value; + setEndDateState(value); + }, []); + + const buildURL = useCallback((state?: Partial) => { + const live = liveStateRef.current; + const nextState: TURLSyncState = { + gpsFilter: state?.gpsFilter ?? live.gpsFilter, + hiddenFilter: state?.hiddenFilter ?? live.hiddenFilter, + viewMode: state?.viewMode ?? live.viewMode, + selectedAlbumID: resolveNullableOverride(state, 'selectedAlbumID', live.selectedAlbumID), + selectedTagID: resolveNullableOverride(state, 'selectedTagID', live.selectedTagID), + pageSize: state?.pageSize ?? live.pageSize, + gridColumns: state?.gridColumns ?? live.gridColumns, + visibleMarkerLimit: state?.visibleMarkerLimit ?? live.visibleMarkerLimit, + startDate: resolveNullableOverride(state, 'startDate', live.startDate), + endDate: resolveNullableOverride(state, 'endDate', live.endDate) + }; + return buildURLFromState(nextState); + }, []); const syncURLAction = useCallback( (state?: Partial) => { @@ -297,6 +353,7 @@ export function useURLState(): TURLState { setHiddenFilter: setHiddenFilterRawAction, setViewMode: setViewModeAction, setSelectedAlbumID: setSelectedAlbumIDAction, + setSelectedTagID: setSelectedTagIDAction, setPageSize: setPageSizeAction, setGridColumns: setGridColumnsAction, setVisibleMarkerLimit: setVisibleMarkerLimitAction, @@ -313,7 +370,19 @@ export function useURLState(): TURLState { return () => { removeListener(); }; - }, [urlSync]); + }, [ + urlSync, + setGPSFilterRawAction, + setHiddenFilterRawAction, + setViewModeAction, + setSelectedAlbumIDAction, + setSelectedTagIDAction, + setPageSizeAction, + setGridColumnsAction, + setVisibleMarkerLimitAction, + setStartDateAction, + setEndDateAction + ]); return { gpsFilter, @@ -330,6 +399,8 @@ export function useURLState(): TURLState { setViewModeAction, selectedAlbumID, setSelectedAlbumIDAction, + selectedTagID, + setSelectedTagIDAction, startDate, setStartDateAction, endDate, diff --git a/src/features/map/hooks/useFocusMapAsset.flow.ts b/src/features/map/hooks/useFocusMapAsset.flow.ts index 901a711..266c85e 100644 --- a/src/features/map/hooks/useFocusMapAsset.flow.ts +++ b/src/features/map/hooks/useFocusMapAsset.flow.ts @@ -7,6 +7,7 @@ type TResolveFocusTargetArgs = { pageSize: number; viewMode: TViewMode; selectedAlbumID: string | null; + selectedTagID: string | null; signal: AbortSignal; }; @@ -33,19 +34,21 @@ export async function resolveFocusTarget({ pageSize, viewMode, selectedAlbumID, + selectedTagID, signal }: TResolveFocusTargetArgs): Promise { const isAlbumMode = viewMode === 'album'; const hasActiveAlbum = isAlbumMode && selectedAlbumID !== null; + const tagParam = selectedTagID ?? undefined; if (hasActiveAlbum && selectedAlbumID) { - const info = await fetchAssetPageInfo(assetID, pageSize, selectedAlbumID, {signal}); + const info = await fetchAssetPageInfo(assetID, pageSize, selectedAlbumID, tagParam, {signal}); return {page: info.page, albumID: selectedAlbumID, requiresAlbumSwitch: false}; } - const info = await fetchAssetPageInfo(assetID, pageSize, undefined, {signal}); + const info = await fetchAssetPageInfo(assetID, pageSize, undefined, tagParam, {signal}); if (isAlbumMode && info.albumID) { - const albumInfo = await fetchAssetPageInfo(assetID, pageSize, info.albumID, {signal}); + const albumInfo = await fetchAssetPageInfo(assetID, pageSize, info.albumID, tagParam, {signal}); return { page: albumInfo.page, albumID: info.albumID, diff --git a/src/features/map/hooks/useFocusMapAsset.ts b/src/features/map/hooks/useFocusMapAsset.ts index 793fce3..262ebec 100644 --- a/src/features/map/hooks/useFocusMapAsset.ts +++ b/src/features/map/hooks/useFocusMapAsset.ts @@ -15,6 +15,7 @@ type TUseFocusMapAssetArgs = { setGPSFilterRawAction: (filter: TGPSFilter) => void; viewMode: TViewMode; selectedAlbumID: string | null; + selectedTagID: string | null; selectAlbumAction: (albumID: string | null) => void; closeLightboxAction: () => void; pageSize: number; @@ -37,6 +38,7 @@ export function useFocusMapAsset({ setGPSFilterRawAction, viewMode, selectedAlbumID, + selectedTagID, selectAlbumAction, closeLightboxAction, pageSize, @@ -67,6 +69,7 @@ export function useFocusMapAsset({ pageSize, viewMode, selectedAlbumID, + selectedTagID, signal: controller.signal }); if (focusRequestIDRef.current !== requestID) { @@ -101,6 +104,7 @@ export function useFocusMapAsset({ setGPSFilterRawAction, viewMode, selectedAlbumID, + selectedTagID, selectAlbumAction, closeLightboxAction, pageSize, diff --git a/src/features/map/hooks/useMapMarkers.ts b/src/features/map/hooks/useMapMarkers.ts index 842683e..1294a01 100644 --- a/src/features/map/hooks/useMapMarkers.ts +++ b/src/features/map/hooks/useMapMarkers.ts @@ -39,6 +39,9 @@ function boundsKey(bounds?: TViewportBounds | null): string { */ export function useMapMarkers( albumID?: string | null, + tagID?: string | null, + startDate?: string | null, + endDate?: string | null, version = 0, bounds?: TViewportBounds | null, visibleMarkerLimit: number = DEFAULT_VISIBLE_MARKER_LIMIT @@ -57,6 +60,9 @@ export function useMapMarkers( const load = useCallback( async ( currentAlbumID?: string, + currentTagID?: string, + currentStartDate?: string, + currentEndDate?: string, currentBounds?: TViewportBounds | null, currentVisibleMarkerLimit: number = DEFAULT_VISIBLE_MARKER_LIMIT ) => { @@ -66,9 +72,15 @@ export function useMapMarkers( const controller = new AbortController(); abortRef.current = controller; try { - const markers = await fetchMapMarkers(currentAlbumID, currentBounds, currentVisibleMarkerLimit, { - signal: controller.signal - }); + const markers = await fetchMapMarkers( + currentAlbumID, + currentTagID, + currentStartDate, + currentEndDate, + currentBounds, + currentVisibleMarkerLimit, + {signal: controller.signal} + ); if (requestIDRef.current !== requestID) { return; } @@ -92,6 +104,9 @@ export function useMapMarkers( ); const prevAlbumID = useRef(albumID); + const prevTagID = useRef(tagID); + const prevStartDate = useRef(startDate); + const prevEndDate = useRef(endDate); const prevVersion = useRef(version); const prevBoundsKey = useRef(currentBoundsKey); const prevVisibleMarkerLimit = useRef(visibleMarkerLimit); @@ -99,36 +114,59 @@ export function useMapMarkers( useEffect(() => { const isAlbumChanged = prevAlbumID.current !== albumID; + const isTagChanged = prevTagID.current !== tagID; + const isStartDateChanged = prevStartDate.current !== startDate; + const isEndDateChanged = prevEndDate.current !== endDate; const isVersionChanged = prevVersion.current !== version; const isBoundsChanged = prevBoundsKey.current !== currentBoundsKey; const isVisibleMarkerLimitChanged = prevVisibleMarkerLimit.current !== visibleMarkerLimit; prevAlbumID.current = albumID; + prevTagID.current = tagID; + prevStartDate.current = startDate; + prevEndDate.current = endDate; prevVersion.current = version; prevBoundsKey.current = currentBoundsKey; prevVisibleMarkerLimit.current = visibleMarkerLimit; - if (!albumID && !effectiveBounds) { + if (!albumID && !tagID && !effectiveBounds) { return; } if ( isAlbumChanged || + isTagChanged || + isStartDateChanged || + isEndDateChanged || isVersionChanged || isBoundsChanged || isVisibleMarkerLimitChanged || !hasLoadedRef.current ) { hasLoadedRef.current = true; - if (isAlbumChanged) { + if (isAlbumChanged || isTagChanged || isStartDateChanged || isEndDateChanged) { setMapMarkers([]); setError(null); - load(albumID ?? undefined, effectiveBounds, visibleMarkerLimit); + load( + albumID ?? undefined, + tagID ?? undefined, + startDate ?? undefined, + endDate ?? undefined, + effectiveBounds, + visibleMarkerLimit + ); return; } - load(albumID ?? undefined, effectiveBounds, visibleMarkerLimit); + load( + albumID ?? undefined, + tagID ?? undefined, + startDate ?? undefined, + endDate ?? undefined, + effectiveBounds, + visibleMarkerLimit + ); } - }, [albumID, effectiveBounds, currentBoundsKey, load, version, visibleMarkerLimit]); + }, [albumID, tagID, startDate, endDate, effectiveBounds, currentBoundsKey, load, version, visibleMarkerLimit]); useEffect(() => { return () => { diff --git a/src/features/map/hooks/useMapViewAutoFit.ts b/src/features/map/hooks/useMapViewAutoFit.ts index 1c30229..9b304ed 100644 --- a/src/features/map/hooks/useMapViewAutoFit.ts +++ b/src/features/map/hooks/useMapViewAutoFit.ts @@ -1,7 +1,7 @@ 'use client'; import L from 'leaflet'; -import {useEffect} from 'react'; +import {useEffect, useRef} from 'react'; import { MAP_DEFAULT_CENTER, @@ -26,6 +26,9 @@ type TUseMapAutoFitArgs = { gpsFilter: TGPSFilter; viewMode: TViewMode; albumFilter: string | null; + tagFilter: string | null; + startDate: string | null; + endDate: string | null; mapMarkers: TMapMarker[]; fittedBoundsKeyRef: RefObject; prevFitAlbumRef: RefObject; @@ -79,10 +82,17 @@ export function useMapAutoFit({ gpsFilter, viewMode, albumFilter, + tagFilter, + startDate, + endDate, mapMarkers, fittedBoundsKeyRef, prevFitAlbumRef }: TUseMapAutoFitArgs): void { + const prevFitTagRef = useRef(tagFilter); + const prevFitStartRef = useRef(startDate); + const prevFitEndRef = useRef(endDate); + useEffect(() => { if (!mapInstanceRef.current) { return; @@ -90,22 +100,29 @@ export function useMapAutoFit({ const map = mapInstanceRef.current; const hasAlbumChanged = prevFitAlbumRef.current !== albumFilter; + const hasTagChanged = prevFitTagRef.current !== tagFilter; + const hasDateChanged = prevFitStartRef.current !== startDate || prevFitEndRef.current !== endDate; prevFitAlbumRef.current = albumFilter; + prevFitTagRef.current = tagFilter; + prevFitStartRef.current = startDate; + prevFitEndRef.current = endDate; - if (hasAlbumChanged) { + // On any filter change the marker set is reloading; defer the fit to the next + // render (when fresh markers have arrived) so we never fit against stale pins. + if (hasAlbumChanged || hasTagChanged || hasDateChanged) { fittedBoundsKeyRef.current = null; - if (!albumFilter) { + if (hasAlbumChanged && !albumFilter) { programmaticMoveRef.current = true; map.flyTo(MAP_DEFAULT_CENTER, MAP_DEFAULT_ZOOM, {duration: MAP_FLY_DURATION_SECONDS}); - return; } + return; } const boundsKey = resolveAutoFitKey(viewMode, gpsFilter, albumFilter, mapMarkers.length); if (!boundsKey) { return; } - if (!hasAlbumChanged && fittedBoundsKeyRef.current === boundsKey) { + if (fittedBoundsKeyRef.current === boundsKey) { return; } fittedBoundsKeyRef.current = boundsKey; @@ -121,6 +138,9 @@ export function useMapAutoFit({ map.flyToBounds(bounds, {padding: MAP_FIT_PADDING, maxZoom, duration: MAP_FLY_DURATION_SECONDS}); }, [ albumFilter, + tagFilter, + startDate, + endDate, fittedBoundsKeyRef, gpsFilter, mapInstanceRef, diff --git a/src/features/map/hooks/useMapViewController.ts b/src/features/map/hooks/useMapViewController.ts index b70c81d..18ed215 100644 --- a/src/features/map/hooks/useMapViewController.ts +++ b/src/features/map/hooks/useMapViewController.ts @@ -39,6 +39,9 @@ type TUseMapViewControllerArgs = { | 'gpsFilter' | 'viewMode' | 'albumFilter' + | 'tagFilter' + | 'startDate' + | 'endDate' | 'setMapBoundsAction' | 'mapMarkers' | 'selectedAssets' @@ -76,6 +79,9 @@ export function useMapViewController({ gpsFilter, viewMode, albumFilter, + tagFilter, + startDate, + endDate, setMapBoundsAction, mapMarkers, selectedAssets, @@ -221,6 +227,9 @@ export function useMapViewController({ gpsFilter, viewMode, albumFilter, + tagFilter, + startDate, + endDate, mapMarkers, fittedBoundsKeyRef, prevFitAlbumRef diff --git a/src/features/map/hooks/useMapViewModel.ts b/src/features/map/hooks/useMapViewModel.ts index e006059..2573369 100644 --- a/src/features/map/hooks/useMapViewModel.ts +++ b/src/features/map/hooks/useMapViewModel.ts @@ -16,6 +16,9 @@ export type TUseMapViewModelReturn = { gpsFilter: TMapSceneValue['gpsFilter']; viewMode: TMapSceneValue['viewMode']; albumFilter: string | null; + tagFilter: string | null; + startDate: string | null; + endDate: string | null; setMapBoundsAction: (bounds: TViewportBounds | null) => void; mapMarkers: ReturnType['mapMarkers']; mapMarkersError: ReturnType['error']; @@ -40,6 +43,9 @@ export function useMapViewModel(): TUseMapViewModelReturn { gpsFilter, viewMode, selectedAlbumID, + selectedTagID, + startDate, + endDate, assets, selectedAssets, pendingLocation, @@ -59,6 +65,9 @@ export function useMapViewModel(): TUseMapViewModelReturn { const [mapBounds, setMapBounds] = useState(null); const {mapMarkers, error: mapMarkersError} = useMapMarkers( albumFilter, + selectedTagID, + startDate, + endDate, mapMarkersVersion, mapBounds, visibleMarkerLimit @@ -136,6 +145,9 @@ export function useMapViewModel(): TUseMapViewModelReturn { gpsFilter, viewMode, albumFilter: effectiveAlbumFilter, + tagFilter: selectedTagID, + startDate, + endDate, setMapBoundsAction: setMapBounds, mapMarkers: effectiveMapMarkers, mapMarkersError, diff --git a/src/features/photoGrid/useAssets.ts b/src/features/photoGrid/useAssets.ts index 49c561f..696dba3 100644 --- a/src/features/photoGrid/useAssets.ts +++ b/src/features/photoGrid/useAssets.ts @@ -26,6 +26,7 @@ export function useAssets( hiddenFilter: THiddenFilter, pageSize: number, albumID?: string | null, + tagID?: string | null, startDate?: string | null, endDate?: string | null, focusPageRef?: MutableRefObject @@ -57,6 +58,7 @@ export function useAssets( gpsFilter, hiddenFilter, albumID ?? undefined, + tagID ?? undefined, startDate ?? undefined, endDate ?? undefined, { @@ -87,10 +89,11 @@ export function useAssets( } } }, - [albumID, gpsFilter, hiddenFilter, startDate, endDate, pageSize] + [albumID, tagID, gpsFilter, hiddenFilter, startDate, endDate, pageSize] ); const prevAlbumID = useRef(albumID); + const prevTagID = useRef(tagID); const prevGPSFilter = useRef(gpsFilter); const prevHiddenFilter = useRef(hiddenFilter); const prevPageSize = useRef(pageSize); @@ -99,6 +102,7 @@ export function useAssets( useEffect(() => { if ( prevAlbumID.current !== albumID || + prevTagID.current !== tagID || prevGPSFilter.current !== gpsFilter || prevHiddenFilter.current !== hiddenFilter || prevPageSize.current !== pageSize || @@ -106,6 +110,7 @@ export function useAssets( prevEndDate.current !== endDate ) { prevAlbumID.current = albumID; + prevTagID.current = tagID; prevGPSFilter.current = gpsFilter; prevHiddenFilter.current = hiddenFilter; prevPageSize.current = pageSize; @@ -120,7 +125,7 @@ export function useAssets( } void loadPageAction(page); } - }, [albumID, gpsFilter, hiddenFilter, pageSize, startDate, endDate, loadPageAction, focusPageRef]); + }, [albumID, tagID, gpsFilter, hiddenFilter, pageSize, startDate, endDate, loadPageAction, focusPageRef]); useEffect(() => { return () => { diff --git a/src/features/tags/useTags.ts b/src/features/tags/useTags.ts new file mode 100644 index 0000000..840a89f --- /dev/null +++ b/src/features/tags/useTags.ts @@ -0,0 +1,65 @@ +'use client'; + +import {useCallback, useEffect, useRef, useState} from 'react'; + +import {fetchTags} from '@/shared/services/backendApi'; + +import type {TTagRow} from '@/shared/types/tag'; + +type TUseTagsResult = { + tags: TTagRow[]; + isLoading: boolean; + error: string | null; + load: () => Promise; +}; + +export function useTags(enabled: boolean): TUseTagsResult { + const [tags, setTags] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + const hasLoadedRef = useRef(false); + + const load = useCallback(async () => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setIsLoading(true); + setError(null); + try { + const result = await fetchTags({signal: controller.signal}); + if (!controller.signal.aborted) { + setTags(result); + hasLoadedRef.current = true; + } + } catch (err) { + if (controller.signal.aborted) { + return; + } + if (err instanceof Error) { + setError(err.message); + } else { + setError('Failed to load tags'); + } + } finally { + if (!controller.signal.aborted) { + setIsLoading(false); + } + } + }, []); + + useEffect(() => { + if (enabled && !hasLoadedRef.current) { + void load(); + } + }, [enabled, load]); + + useEffect(() => { + return () => { + abortRef.current?.abort(); + }; + }, []); + + return {tags, isLoading, error, load}; +} diff --git a/src/shared/components/PhotoList.tsx b/src/shared/components/PhotoList.tsx index f176b94..0fc9d51 100644 --- a/src/shared/components/PhotoList.tsx +++ b/src/shared/components/PhotoList.tsx @@ -45,6 +45,8 @@ type TPhotoListProps = { startDate: string | null; endDate: string | null; onDateRangeAction: (startDate: string | null, endDate: string | null) => void; + selectedTagID: string | null; + onTagAction: (tagID: string | null) => void; gpxPreviews: TGPXPreviewResponse[]; gpxError: string | null; onGPXResetAction: () => void; @@ -115,6 +117,8 @@ export function PhotoList({backend, view, catalog, selection}: TPhotoListProps): startDate, endDate, onDateRangeAction, + selectedTagID, + onTagAction, gpxPreviews, gpxError, onGPXResetAction, @@ -180,6 +184,9 @@ export function PhotoList({backend, view, catalog, selection}: TPhotoListProps): startDate={startDate} endDate={endDate} onDateRangeAction={onDateRangeAction} + selectedTagID={selectedTagID} + onTagAction={onTagAction} + tagFilterDisabled={shouldShowAlbumList} isSyncing={isSyncing} syncError={syncError} onSyncAction={onRetrySyncAction} diff --git a/src/shared/components/PhotoListContainer.tsx b/src/shared/components/PhotoListContainer.tsx index b2a67dc..aa7e3cd 100644 --- a/src/shared/components/PhotoListContainer.tsx +++ b/src/shared/components/PhotoListContainer.tsx @@ -30,6 +30,8 @@ export function PhotoListContainer(): ReactElement { setViewModeAction, selectedAlbumID, selectAlbumAction, + selectedTagID, + selectTagAction, startDate, endDate, setDateRangeAction @@ -126,6 +128,9 @@ export function PhotoListContainer(): ReactElement { return; } closeLightboxAction(); + if (mode === 'album') { + selectTagAction(null); + } setViewModeAction(mode); }; @@ -224,6 +229,8 @@ export function PhotoListContainer(): ReactElement { startDate, endDate, onDateRangeAction: setDateRangeAction, + selectedTagID, + onTagAction: selectTagAction, onGPXResetAction: handleGPXAutoReset, onGPXCancelAction: handleGPXCancel, trailingAction: , diff --git a/src/shared/context/useAppProviderState.ts b/src/shared/context/useAppProviderState.ts index 828077e..f4d7e6b 100644 --- a/src/shared/context/useAppProviderState.ts +++ b/src/shared/context/useAppProviderState.ts @@ -83,10 +83,12 @@ export function useAppProviderState(): TAppProviderState { viewMode, setViewModeAction, selectedAlbumID, + selectedTagID, startDate, endDate, setGPSFilterAction, selectAlbumAction, + selectTagAction, setDateRangeAction } = useViewDomain(); @@ -96,6 +98,7 @@ export function useAppProviderState(): TAppProviderState { pageSize, viewMode, selectedAlbumID, + selectedTagID, startDate, endDate, isReady @@ -149,6 +152,7 @@ export function useAppProviderState(): TAppProviderState { setGPSFilterRawAction, viewMode, selectedAlbumID, + selectedTagID, selectAlbumAction, pageSize, loadPageAction: catalogDomain.loadPageAction, @@ -214,6 +218,8 @@ export function useAppProviderState(): TAppProviderState { setViewModeAction, selectedAlbumID, selectAlbumAction, + selectedTagID, + selectTagAction, startDate, endDate, setDateRangeAction @@ -275,6 +281,9 @@ export function useAppProviderState(): TAppProviderState { gpsFilter, viewMode, selectedAlbumID, + selectedTagID, + startDate, + endDate, assets: catalogDomain.assets, selectedAssets, pendingLocation, diff --git a/src/shared/context/useCatalogDomain.ts b/src/shared/context/useCatalogDomain.ts index 2e5ef83..951b0ee 100644 --- a/src/shared/context/useCatalogDomain.ts +++ b/src/shared/context/useCatalogDomain.ts @@ -16,6 +16,7 @@ type TUseCatalogDomainArgs = { pageSize: number; viewMode: TViewMode; selectedAlbumID: string | null; + selectedTagID: string | null; startDate: string | null; endDate: string | null; isReady: boolean; @@ -43,6 +44,7 @@ export function useCatalogDomain({ pageSize, viewMode, selectedAlbumID, + selectedTagID, startDate, endDate, isReady @@ -62,7 +64,7 @@ export function useCatalogDomain({ removeAsset, loadPageAction, clear: clearAssets - } = useAssets(gpsFilter, hiddenFilter, pageSize, albumFilter, startDate, endDate, focusPageRef); + } = useAssets(gpsFilter, hiddenFilter, pageSize, albumFilter, selectedTagID, startDate, endDate, focusPageRef); const { albums, isLoading: isLoadingAlbums, diff --git a/src/shared/context/useProviderValues.ts b/src/shared/context/useProviderValues.ts index 713e516..b961cbd 100644 --- a/src/shared/context/useProviderValues.ts +++ b/src/shared/context/useProviderValues.ts @@ -57,6 +57,8 @@ export function useViewValue(input: TViewContextValue): TViewContextValue { setViewModeAction: input.setViewModeAction, selectedAlbumID: input.selectedAlbumID, selectAlbumAction: input.selectAlbumAction, + selectedTagID: input.selectedTagID, + selectTagAction: input.selectTagAction, startDate: input.startDate, endDate: input.endDate, setDateRangeAction: input.setDateRangeAction @@ -76,6 +78,8 @@ export function useViewValue(input: TViewContextValue): TViewContextValue { input.setViewModeAction, input.selectedAlbumID, input.selectAlbumAction, + input.selectedTagID, + input.selectTagAction, input.startDate, input.endDate, input.setDateRangeAction @@ -202,6 +206,9 @@ export function useMapSceneValue(input: TMapSceneValue): TMapSceneValue { gpsFilter: input.gpsFilter, viewMode: input.viewMode, selectedAlbumID: input.selectedAlbumID, + selectedTagID: input.selectedTagID, + startDate: input.startDate, + endDate: input.endDate, assets: input.assets, selectedAssets: input.selectedAssets, pendingLocation: input.pendingLocation, @@ -220,6 +227,9 @@ export function useMapSceneValue(input: TMapSceneValue): TMapSceneValue { input.gpsFilter, input.viewMode, input.selectedAlbumID, + input.selectedTagID, + input.startDate, + input.endDate, input.assets, input.selectedAssets, input.pendingLocation, diff --git a/src/shared/context/useUIMapController.ts b/src/shared/context/useUIMapController.ts index 7f42967..0631e73 100644 --- a/src/shared/context/useUIMapController.ts +++ b/src/shared/context/useUIMapController.ts @@ -16,6 +16,7 @@ type TUIMapControllerArgs = { setGPSFilterRawAction: (filter: TGPSFilter) => void; viewMode: TViewMode; selectedAlbumID: string | null; + selectedTagID: string | null; selectAlbumAction: (albumID: string | null) => void; pageSize: number; loadPageAction: (page: number) => Promise; @@ -37,6 +38,7 @@ export function useUIMapController({ setGPSFilterRawAction, viewMode, selectedAlbumID, + selectedTagID, selectAlbumAction, pageSize, loadPageAction, @@ -63,6 +65,7 @@ export function useUIMapController({ setGPSFilterRawAction, viewMode, selectedAlbumID, + selectedTagID, selectAlbumAction, closeLightboxAction, pageSize, diff --git a/src/shared/context/useViewDomain.ts b/src/shared/context/useViewDomain.ts index a560061..85fbb34 100644 --- a/src/shared/context/useViewDomain.ts +++ b/src/shared/context/useViewDomain.ts @@ -1,6 +1,6 @@ 'use client'; -import {useCallback} from 'react'; +import {useCallback, useEffect} from 'react'; import {useURLState} from '@/features/filterBar/useURLState'; import {clampVisibleMarkerLimit} from '@/utils/view'; @@ -21,14 +21,17 @@ type TViewDomain = { viewMode: TViewMode; setViewModeAction: (mode: TViewMode) => void; selectedAlbumID: string | null; + selectedTagID: string | null; startDate: string | null; endDate: string | null; setGPSFilterRawAction: (filter: TGPSFilter) => void; setHiddenFilterRawAction: (filter: THiddenFilter) => void; setSelectedAlbumIDAction: (albumID: string | null) => void; + setSelectedTagIDAction: (tagID: string | null) => void; setGPSFilterAction: TViewContextValue['setGPSFilterAction']; setHiddenFilterAction: TViewContextValue['setHiddenFilterAction']; selectAlbumAction: TViewContextValue['selectAlbumAction']; + selectTagAction: TViewContextValue['selectTagAction']; setDateRangeAction: (startDate: string | null, endDate: string | null) => void; }; @@ -48,6 +51,8 @@ export function useViewDomain(): TViewDomain { setViewModeAction, selectedAlbumID, setSelectedAlbumIDAction, + selectedTagID, + setSelectedTagIDAction, startDate, setStartDateAction, endDate, @@ -130,6 +135,14 @@ export function useViewDomain(): TViewDomain { [setSelectedAlbumIDAction, syncURLAction] ); + const selectTagAction = useCallback( + (tagID: string | null) => { + setSelectedTagIDAction(tagID); + syncURLAction({selectedTagID: tagID}); + }, + [setSelectedTagIDAction, syncURLAction] + ); + const setDateRangeAction = useCallback( (nextStartDate: string | null, nextEndDate: string | null) => { setStartDateAction(nextStartDate); @@ -139,6 +152,12 @@ export function useViewDomain(): TViewDomain { [setStartDateAction, setEndDateAction, syncURLAction] ); + useEffect(() => { + if (viewMode === 'album' && selectedAlbumID === null && selectedTagID !== null) { + selectTagAction(null); + } + }, [viewMode, selectedAlbumID, selectedTagID, selectTagAction]); + return { gpsFilter, hiddenFilter, @@ -153,12 +172,15 @@ export function useViewDomain(): TViewDomain { viewMode, setViewModeAction: handleSetViewMode, selectedAlbumID, + selectedTagID, startDate, endDate, setSelectedAlbumIDAction, + setSelectedTagIDAction, setGPSFilterAction: setGPSFilter, setHiddenFilterAction: setHiddenFilter, selectAlbumAction, + selectTagAction, setDateRangeAction }; } diff --git a/src/shared/services/backendApi.guards.ts b/src/shared/services/backendApi.guards.ts index 4bc5414..0cdcca1 100644 --- a/src/shared/services/backendApi.guards.ts +++ b/src/shared/services/backendApi.guards.ts @@ -8,6 +8,7 @@ import type {THealthResponse} from '@/shared/types/health'; import type {TLibraryRow} from '@/shared/types/library'; import type {TMapMarker} from '@/shared/types/map'; import type {TLocationCluster, TRawSuggestionsResponse, TSuggestionsResponse} from '@/shared/types/suggestion'; +import type {TTagRow} from '@/shared/types/tag'; /** * Shared internal row predicate for asset payload entries. @@ -139,6 +140,20 @@ export function isAlbumRow(value: unknown): value is TAlbumRow { ); } +export function isTagRow(value: unknown): value is TTagRow { + if (!isRecord(value)) { + return false; + } + return ( + isString(value.immichID) && + isString(value.name) && + isString(value.value) && + isNullableString(value.parentID) && + isNullableString(value.color) && + isFiniteNumber(value.assetCount) + ); +} + /** * Type guard for map marker payloads. * diff --git a/src/shared/services/backendApi.ts b/src/shared/services/backendApi.ts index 4df09b3..497cf31 100644 --- a/src/shared/services/backendApi.ts +++ b/src/shared/services/backendApi.ts @@ -11,7 +11,8 @@ import { isSyncStatus, isTLocationCluster, isTRawSuggestionsResponse, - isTSuggestionsResponse + isTSuggestionsResponse, + isTagRow } from '@/shared/services/backendApi.guards'; import {getBackendBaseURL} from '@/utils/backendUrls'; import {normalizePositiveInteger} from '@/utils/math'; @@ -27,6 +28,7 @@ import type {THealthResponse} from '@/shared/types/health'; import type {TLibraryRow} from '@/shared/types/library'; import type {TGPSFilter, THiddenFilter, TMapMarker} from '@/shared/types/map'; import type {TLocationCluster, TSuggestionsResponse} from '@/shared/types/suggestion'; +import type {TTagRow} from '@/shared/types/tag'; const BASE = getBackendBaseURL(); @@ -94,6 +96,7 @@ export async function fetchAssets( gpsFilter: TGPSFilter, hiddenFilter: THiddenFilter, albumID?: string, + tagID?: string, startDate?: string, endDate?: string, opts: TRequestOptions = {} @@ -107,6 +110,9 @@ export async function fetchAssets( if (albumID) { params.set('albumID', albumID); } + if (tagID) { + params.set('tagID', tagID); + } if (startDate) { params.set('startDate', startDate); } @@ -134,12 +140,16 @@ export async function fetchAssetDayCounts( gpsFilter: TGPSFilter, hiddenFilter: THiddenFilter, albumID?: string, + tagID?: string, opts: TRequestOptions = {} ): Promise> { const params = buildSearchParams({startDate, endDate, gpsFilter, hiddenFilter}); if (albumID) { params.set('albumID', albumID); } + if (tagID) { + params.set('tagID', tagID); + } const url = `${BASE}/assets/day-counts?${params.toString()}`; const response = await backendFetch(url, {}, opts); if (!response.ok) { @@ -148,6 +158,18 @@ export async function fetchAssetDayCounts( return parseJSON(response, isDayCounts, 'Invalid day counts response payload'); } +export async function fetchTags(opts: TRequestOptions = {}): Promise { + const response = await backendFetch(`${BASE}/tags`, {}, opts); + if (!response.ok) { + throw new Error(`Failed to fetch tags: ${response.status}`); + } + return parseJSON( + response, + (value): value is TTagRow[] => Array.isArray(value) && value.every(isTagRow), + 'Invalid tags response payload' + ); +} + export async function fetchAlbums( gpsFilter: TGPSFilter, startDate?: string, @@ -168,6 +190,9 @@ export async function fetchAlbums( export async function fetchMapMarkers( albumID?: string, + tagID?: string, + startDate?: string, + endDate?: string, bounds?: TViewportBounds | null, limit: number = DEFAULT_VISIBLE_MARKER_LIMIT, opts: TRequestOptions = {} @@ -176,6 +201,15 @@ export async function fetchMapMarkers( if (albumID) { params.set('albumID', albumID); } + if (tagID) { + params.set('tagID', tagID); + } + if (startDate) { + params.set('startDate', startDate); + } + if (endDate) { + params.set('endDate', endDate); + } addIfNumber(params, 'limit', limit); if (bounds) { addIfNumber(params, 'north', bounds.north); @@ -286,12 +320,16 @@ export async function fetchAssetPageInfo( assetID: string, pageSize: number, albumID?: string, + tagID?: string, opts: TRequestOptions = {} ): Promise { const params = buildSearchParams({pageSize: String(normalizePageSize(pageSize))}); if (albumID) { params.set('albumID', albumID); } + if (tagID) { + params.set('tagID', tagID); + } const url = `${BASE}/assets/${encodeURIComponent(assetID)}/page-info?${params.toString()}`; const response = await backendFetch(url, {}, opts); if (!response.ok) { diff --git a/src/shared/types/context.ts b/src/shared/types/context.ts index 437b786..0c4b4ed 100644 --- a/src/shared/types/context.ts +++ b/src/shared/types/context.ts @@ -40,6 +40,8 @@ export type TViewContextValue = { setViewModeAction: (mode: TViewMode) => void; selectedAlbumID: string | null; selectAlbumAction: (albumID: string | null) => void; + selectedTagID: string | null; + selectTagAction: (tagID: string | null) => void; startDate: string | null; endDate: string | null; setDateRangeAction: (startDate: string | null, endDate: string | null) => void; @@ -118,6 +120,9 @@ export type TMapSceneValue = { gpsFilter: TViewContextValue['gpsFilter']; viewMode: TViewContextValue['viewMode']; selectedAlbumID: string | null; + selectedTagID: string | null; + startDate: string | null; + endDate: string | null; assets: TAssetRow[]; selectedAssets: TSelectionContextValue['selectedAssets']; pendingLocation: TSelectionContextValue['pendingLocation']; diff --git a/src/shared/types/tag.ts b/src/shared/types/tag.ts new file mode 100644 index 0000000..9f18012 --- /dev/null +++ b/src/shared/types/tag.ts @@ -0,0 +1,8 @@ +export type TTagRow = { + immichID: string; + name: string; + value: string; + parentID: string | null; + color: string | null; + assetCount: number; +}; diff --git a/src/utils/view.ts b/src/utils/view.ts index b8926d1..a3a9283 100644 --- a/src/utils/view.ts +++ b/src/utils/view.ts @@ -24,6 +24,7 @@ export const GRID_COLUMN_OPTIONS = [1, 2, 3, 4] as const; export const URL_PARAM_GPS_FILTER = 'gps'; export const URL_PARAM_VIEW_MODE = 'view'; export const URL_PARAM_ALBUM_ID = 'album'; +export const URL_PARAM_TAG_ID = 'tag'; export const URL_PARAM_PAGE_SIZE = 'pageSize'; export const URL_PARAM_GRID_COLUMNS = 'gridCols'; export const URL_PARAM_MARKER_LIMIT = 'markerLimit';