From 29f5af610b2a4ed62454d2ca2fc2afb2daf8bc0d Mon Sep 17 00:00:00 2001 From: VasuS609 Date: Tue, 16 Dec 2025 05:37:10 +0530 Subject: [PATCH 1/2] feat(folder-ui): add bulk AI tagging controls, smart sorting, and progress summary --- backend/DATABASE_MIGRATIONS.md | 101 +++++ backend/app/logging/setup_logging.py | 18 +- backend/migrate_database.py | 55 +++ docs/backend/backend_python/openapi.json | 10 +- frontend/package-lock.json | 50 +-- frontend/src/hooks/useFolderOperations.tsx | 28 ++ .../components/FolderManagementCard.tsx | 403 +++++++++++++++--- 7 files changed, 558 insertions(+), 107 deletions(-) create mode 100644 backend/DATABASE_MIGRATIONS.md create mode 100644 backend/migrate_database.py diff --git a/backend/DATABASE_MIGRATIONS.md b/backend/DATABASE_MIGRATIONS.md new file mode 100644 index 000000000..0339f1bc4 --- /dev/null +++ b/backend/DATABASE_MIGRATIONS.md @@ -0,0 +1,101 @@ +# Database Migration Guide + +## Quick Fix for "no such column" Errors + +If you see errors like: +``` +[BACKEND] | ERROR | Error getting all images: no such column: i.isFavourite +``` + +### Solution 1: Run Migration Script (Recommended) +This preserves your existing data: + +```bash +cd backend +python migrate_database.py +``` + +### Solution 2: Reset Database (Nuclear Option) +⚠️ **Warning: This deletes all your data!** + +```bash +cd backend +python reset_database.py +``` + +Then restart the backend server. + +## How Migrations Work + +The application now includes automatic schema migrations: + +1. **Automatic Migration**: When the app starts, it checks if required columns exist +2. **Zero Downtime**: Migrations run transparently without data loss +3. **Safe Defaults**: New columns are added with sensible default values + +## Manual Migration + +If you need to run migrations manually: + +```python +from app.database.images import db_create_images_table + +# This will create tables and run any necessary migrations +db_create_images_table() +``` + +## Adding New Columns (For Developers) + +When adding new database columns: + +1. **Update the CREATE TABLE statement** in the respective database file +2. **Add migration logic** to the `_check_and_migrate_schema()` function +3. **Test the migration** on a copy of the production database +4. **Document the change** in this file + +Example: +```python +def _check_and_migrate_schema(conn: sqlite3.Connection) -> None: + cursor = conn.cursor() + cursor.execute("PRAGMA table_info(images)") + columns = [col[1] for col in cursor.fetchall()] + + # Add new column if it doesn't exist + if "newColumn" not in columns: + logger.info("Adding 'newColumn' to images table") + cursor.execute("ALTER TABLE images ADD COLUMN newColumn TEXT DEFAULT ''") + conn.commit() +``` + +## Troubleshooting + +### Migration Fails +- Check database file permissions +- Ensure no other process is locking the database +- Check available disk space + +### Column Still Missing After Migration +- Restart the backend server +- Check logs for migration success message +- Verify the database file path in settings + +### Database is Corrupted +If migration fails due to corruption: +1. Backup your database file (`*.db`) +2. Try SQLite recovery tools +3. As last resort, use `reset_database.py` (data loss!) + +## Database Schema Version + +Current schema includes: +- ✅ `images.isFavourite` (added: Dec 2025) +- ✅ `images.isTagged` +- ✅ `images.metadata` +- ✅ `image_classes` junction table + +## Future Migrations + +Planned schema changes: +- [ ] Add `rating` column for star ratings (1-5) +- [ ] Add `dateModified` column for edit tracking +- [ ] Add `albums` table for album management diff --git a/backend/app/logging/setup_logging.py b/backend/app/logging/setup_logging.py index e64424654..f0ccdb943 100644 --- a/backend/app/logging/setup_logging.py +++ b/backend/app/logging/setup_logging.py @@ -235,19 +235,17 @@ def emit(self, record: logging.LogRecord) -> None: Args: record: The log record to process """ - # Get the appropriate module name - module_name = record.name - if "." in module_name: - module_name = module_name.split(".")[-1] + # Prevent recursive interception: mark records once handled + if getattr(record, "_intercepted", False): + return + setattr(record, "_intercepted", True) - # Create a message that includes the original module in the format + # Create message (original module name preserved in formatted output by formatter) msg = record.getMessage() - # Find the appropriate logger - logger = get_logger(module_name) - - # Log the message with our custom formatting - logger.log(record.levelno, f"[uvicorn] {msg}") + # Route directly to root logger to avoid re-triggering intercept handlers + root_logger = logging.getLogger() + root_logger.log(record.levelno, f"[uvicorn] {msg}") def configure_uvicorn_logging(component_name: str) -> None: diff --git a/backend/migrate_database.py b/backend/migrate_database.py new file mode 100644 index 000000000..c918c4584 --- /dev/null +++ b/backend/migrate_database.py @@ -0,0 +1,55 @@ +""" +Database Migration Script +Adds the isFavourite column to existing images table if it doesn't exist. +""" + +import sqlite3 +from app.config.settings import DATABASE_PATH +from app.logging.setup_logging import get_logger + +logger = get_logger(__name__) + + +def check_column_exists(cursor, table_name: str, column_name: str) -> bool: + """Check if a column exists in a table.""" + cursor.execute(f"PRAGMA table_info({table_name})") + columns = [col[1] for col in cursor.fetchall()] + return column_name in columns + + +def migrate_database(): + """Add missing columns to the images table.""" + try: + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + + # Check if isFavourite column exists + if not check_column_exists(cursor, "images", "isFavourite"): + logger.info("Adding 'isFavourite' column to images table...") + cursor.execute( + """ + ALTER TABLE images + ADD COLUMN isFavourite BOOLEAN DEFAULT 0 + """ + ) + conn.commit() + logger.info("✓ Successfully added 'isFavourite' column") + else: + logger.info("✓ 'isFavourite' column already exists") + + conn.close() + print("\n✅ Database migration completed successfully!") + + except sqlite3.Error as e: + logger.error(f"Database migration failed: {e}") + print(f"\n❌ Migration failed: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error during migration: {e}") + print(f"\n❌ Unexpected error: {e}") + raise + + +if __name__ == "__main__": + print("Starting database migration...") + migrate_database() diff --git a/docs/backend/backend_python/openapi.json b/docs/backend/backend_python/openapi.json index 44eb908b1..a29e7c4f1 100644 --- a/docs/backend/backend_python/openapi.json +++ b/docs/backend/backend_python/openapi.json @@ -1117,9 +1117,14 @@ "in": "query", "required": false, "schema": { - "$ref": "#/components/schemas/InputType", + "allOf": [ + { + "$ref": "#/components/schemas/InputType" + } + ], "description": "Choose input type: 'path' or 'base64'", - "default": "path" + "default": "path", + "title": "Input Type" }, "description": "Choose input type: 'path' or 'base64'" } @@ -2199,7 +2204,6 @@ "metadata": { "anyOf": [ { - "additionalProperties": true, "type": "object" }, { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e1e1ddd5f..5c4674b22 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -128,6 +128,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -780,6 +781,7 @@ "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1705,6 +1707,7 @@ "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-module-imports": "^7.27.1", @@ -5354,8 +5357,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5533,6 +5535,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5550,6 +5553,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5560,6 +5564,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5689,6 +5694,7 @@ "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -5935,6 +5941,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6641,6 +6648,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -7349,8 +7357,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/domexception": { "version": "4.0.0", @@ -7709,6 +7716,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -9753,6 +9761,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -11237,7 +11246,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -11924,6 +11932,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11956,6 +11965,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12059,7 +12069,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -12075,7 +12084,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -12195,6 +12203,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12253,6 +12262,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -12288,14 +12298,14 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -12457,7 +12467,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -13420,6 +13431,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13560,6 +13572,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -13792,6 +13805,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14050,6 +14064,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -14182,6 +14197,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14476,20 +14492,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/frontend/src/hooks/useFolderOperations.tsx b/frontend/src/hooks/useFolderOperations.tsx index 0c0fcc559..d52d0c248 100644 --- a/frontend/src/hooks/useFolderOperations.tsx +++ b/frontend/src/hooks/useFolderOperations.tsx @@ -160,6 +160,30 @@ export const useFolderOperations = () => { deleteFolderMutation.mutate(folderId); }; + // Bulk mutations for AI tagging + const enableBulkAITaggingMutation = usePictoMutation({ + mutationFn: async (folder_ids: string[]) => enableAITagging({ folder_ids }), + autoInvalidateTags: ['folders'], + }); + + const disableBulkAITaggingMutation = usePictoMutation({ + mutationFn: async (folder_ids: string[]) => + disableAITagging({ folder_ids }), + autoInvalidateTags: ['folders'], + }); + + const tagAllFolders = () => { + const ids = folders.filter((f) => !f.AI_Tagging).map((f) => f.folder_id); + if (ids.length > 0) enableBulkAITaggingMutation.mutate(ids); + }; + + const tagSelectedFolders = (selectedIds: string[]) => { + const pending = selectedIds.filter( + (id) => !folders.find((f) => f.folder_id === id)?.AI_Tagging, + ); + if (pending.length > 0) enableBulkAITaggingMutation.mutate(pending); + }; + return { // Data folders, @@ -168,11 +192,15 @@ export const useFolderOperations = () => { // Operations toggleAITagging, deleteFolder, + tagAllFolders, + tagSelectedFolders, // Mutation states (for use in UI, e.g., disabling buttons) enableAITaggingPending: enableAITaggingMutation.isPending, disableAITaggingPending: disableAITaggingMutation.isPending, deleteFolderPending: deleteFolderMutation.isPending, + bulkEnablePending: enableBulkAITaggingMutation.isPending, + bulkDisablePending: disableBulkAITaggingMutation.isPending, }; }; diff --git a/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx b/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx index db4b029fa..198c214cd 100644 --- a/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx +++ b/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import { Folder, Trash2, Check } from 'lucide-react'; import { Switch } from '@/components/ui/switch'; @@ -20,15 +20,86 @@ const FolderManagementCard: React.FC = () => { folders, toggleAITagging, deleteFolder, + tagAllFolders, + tagSelectedFolders, enableAITaggingPending, disableAITaggingPending, deleteFolderPending, + bulkEnablePending, } = useFolderOperations(); const taggingStatus = useSelector( (state: RootState) => state.folders.taggingStatus, ); + // Local selection state + const [selected, setSelected] = useState>({}); + const allSelected = useMemo( + () => folders.length > 0 && folders.every((f) => selected[f.folder_id]), + [folders, selected], + ); + + const toggleSelectAll = () => { + if (allSelected) { + setSelected({}); + } else { + const map: Record = {}; + folders.forEach((f) => (map[f.folder_id] = true)); + setSelected(map); + } + }; + + const selectedIds = useMemo( + () => + Object.entries(selected) + .filter(([, v]) => v) + .map(([id]) => id), + [selected], + ); + + // Sorting into groups: Completed, In Progress, Pending + const groups = useMemo(() => { + const completed: FolderDetails[] = []; + const inProgress: FolderDetails[] = []; + const pending: FolderDetails[] = []; + + folders.forEach((f) => { + const percentage = taggingStatus[f.folder_id]?.tagging_percentage ?? 0; + if (f.AI_Tagging) { + if (percentage >= 100 || (f as any).taggingCompleted) { + completed.push(f); + } else { + inProgress.push(f); + } + } else { + pending.push(f); + } + }); + + return { completed, inProgress, pending }; + }, [folders, taggingStatus]); + + const totals = useMemo(() => { + const total = folders.length; + const completedCount = groups.completed.length; + const inProgressCount = groups.inProgress.length; + const pendingCount = groups.pending.length; + const taggedCount = completedCount + inProgressCount; // AI_Tagging enabled + const progressPct = total > 0 ? Math.round((taggedCount / total) * 100) : 0; + return { + total, + completedCount, + inProgressCount, + pendingCount, + taggedCount, + progressPct, + }; + }, [folders.length, groups]); + + const [showCompleted, setShowCompleted] = useState(true); + const [showInProgress, setShowInProgress] = useState(true); + const [showPending, setShowPending] = useState(true); + return ( { description="Configure your photo library folders and AI settings" > {folders.length > 0 ? ( -
- {folders.map((folder: FolderDetails, index: number) => ( -
-
-
-
- - - {folder.folder_path} - -
+ <> + {/* Progress Summary & Bulk Controls */} +
+
+
+
+ AI Tagging Progress: {totals.taggedCount}/{totals.total}{' '} + folders tagged ({totals.progressPct}%) +
+
+ Completed: {totals.completedCount} | In Progress:{' '} + {totals.inProgressCount} | Pending: {totals.pendingCount}
+
+
+ + + +
+
+ +
-
-
- - AI Tagging - - toggleAITagging(folder)} - disabled={ - enableAITaggingPending || disableAITaggingPending + {/* Smart Sorting Groups */} +
+ {/* Completed */} +
+
+

+ Completed (AI tagged) +

+ +
+ {showCompleted && ( +
+ {groups.completed.map((folder: FolderDetails) => ( + + setSelected((prev) => ({ + ...prev, + [folder.folder_id]: !prev[folder.folder_id], + })) } + toggleAITagging={toggleAITagging} + deleteFolder={deleteFolder} + enableAITaggingPending={enableAITaggingPending} + disableAITaggingPending={disableAITaggingPending} + deleteFolderPending={deleteFolderPending} /> -
- - + ))} + {groups.completed.length === 0 && ( +
+ No completed folders +
+ )}
+ )} +
+ + {/* In Progress */} +
+
+

+ In Progress (tagging...) +

+
+ {showInProgress && ( +
+ {groups.inProgress.map((folder: FolderDetails) => ( + + setSelected((prev) => ({ + ...prev, + [folder.folder_id]: !prev[folder.folder_id], + })) + } + toggleAITagging={toggleAITagging} + deleteFolder={deleteFolder} + enableAITaggingPending={enableAITaggingPending} + disableAITaggingPending={disableAITaggingPending} + deleteFolderPending={deleteFolderPending} + /> + ))} + {groups.inProgress.length === 0 && ( +
+ No folders are currently tagging +
+ )} +
+ )} +
- {folder.AI_Tagging && ( -
-
- AI Tagging Progress - = 100 - ? 'flex items-center gap-1 text-green-500' - : 'text-muted-foreground' + {/* Pending */} +
+
+

+ Pending (not yet tagged) +

+ +
+ {showPending && ( +
+ {groups.pending.map((folder: FolderDetails) => ( + + setSelected((prev) => ({ + ...prev, + [folder.folder_id]: !prev[folder.folder_id], + })) } - > - {(taggingStatus[folder.folder_id]?.tagging_percentage ?? - 0) >= 100 && } - {Math.round( - taggingStatus[folder.folder_id]?.tagging_percentage ?? - 0, - )} - % - -
- = 100 - ? 'bg-green-500' - : 'bg-blue-500' - } - /> + toggleAITagging={toggleAITagging} + deleteFolder={deleteFolder} + enableAITaggingPending={enableAITaggingPending} + disableAITaggingPending={disableAITaggingPending} + deleteFolderPending={deleteFolderPending} + /> + ))} + {groups.pending.length === 0 && ( +
+ No pending folders +
+ )}
)}
- ))} -
+
+ ) : (
@@ -135,4 +307,95 @@ const FolderManagementCard: React.FC = () => { ); }; +// Reusable row component for a folder item +const FolderRow: React.FC<{ + folder: FolderDetails; + taggingStatus?: { tagging_percentage?: number }; + selected: boolean; + onToggleSelect: () => void; + toggleAITagging: (folder: FolderDetails) => void; + deleteFolder: (folderId: string) => void; + enableAITaggingPending: boolean; + disableAITaggingPending: boolean; + deleteFolderPending: boolean; +}> = ({ + folder, + taggingStatus, + selected, + onToggleSelect, + toggleAITagging, + deleteFolder, + enableAITaggingPending, + disableAITaggingPending, + deleteFolderPending, +}) => { + const percentage = Math.round(taggingStatus?.tagging_percentage ?? 0); + const isComplete = (taggingStatus?.tagging_percentage ?? 0) >= 100; + return ( +
+
+
+
+ {/* Selection checkbox */} + + + + {folder.folder_path} + +
+
+ +
+
+ AI Tagging + toggleAITagging(folder)} + disabled={enableAITaggingPending || disableAITaggingPending} + /> +
+ + +
+
+ + {folder.AI_Tagging && ( +
+
+ AI Tagging Progress + + {isComplete && } + {percentage}% + +
+ +
+ )} +
+ ); +}; + export default FolderManagementCard; From 6084ff94053a044bddaa8d89bcbf4a8e310bd9b8 Mon Sep 17 00:00:00 2001 From: VasuS609 Date: Tue, 16 Dec 2025 23:36:46 +0530 Subject: [PATCH 2/2] fix: refactor totals memoization to rely on grouped progress data --- .../components/FolderManagementCard.tsx | 54 ++++++++++++++----- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx b/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx index 198c214cd..2e6834955 100644 --- a/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx +++ b/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx @@ -12,6 +12,20 @@ import { useFolderOperations } from '@/hooks/useFolderOperations'; import { FolderDetails } from '@/types/Folder'; import SettingsCard from './SettingsCard'; +const getFolderProgress = ( + folder: FolderDetails, + taggingStatus?: { tagging_percentage?: number }, +) => { + if (folder.taggingCompleted) return 100; + if (typeof taggingStatus?.tagging_percentage === 'number') { + return Math.max( + 0, + Math.min(100, Math.round(taggingStatus.tagging_percentage)), + ); + } + return 0; +}; + /** * Component for managing folder operations in settings */ @@ -57,35 +71,47 @@ const FolderManagementCard: React.FC = () => { [selected], ); - // Sorting into groups: Completed, In Progress, Pending + // Sorting into groups with progress: Completed, In Progress, Pending + // This enriches each folder with its progress percentage for use by totals const groups = useMemo(() => { - const completed: FolderDetails[] = []; - const inProgress: FolderDetails[] = []; - const pending: FolderDetails[] = []; + const completed: Array = []; + const inProgress: Array = []; + const pending: Array = []; folders.forEach((f) => { - const percentage = taggingStatus[f.folder_id]?.tagging_percentage ?? 0; + const progress = getFolderProgress(f, taggingStatus[f.folder_id]); + const folderWithProgress = { ...f, progress }; + if (f.AI_Tagging) { - if (percentage >= 100 || (f as any).taggingCompleted) { - completed.push(f); + if (progress >= 100) { + completed.push(folderWithProgress); } else { - inProgress.push(f); + inProgress.push(folderWithProgress); } } else { - pending.push(f); + pending.push(folderWithProgress); } }); return { completed, inProgress, pending }; }, [folders, taggingStatus]); + // Calculate totals using only grouped data + // No direct access to folders or taggingStatus - all data comes through groups const totals = useMemo(() => { - const total = folders.length; const completedCount = groups.completed.length; const inProgressCount = groups.inProgress.length; const pendingCount = groups.pending.length; + const total = completedCount + inProgressCount + pendingCount; const taggedCount = completedCount + inProgressCount; // AI_Tagging enabled - const progressPct = total > 0 ? Math.round((taggedCount / total) * 100) : 0; + + // Calculate progress sum from pre-computed progress in groups + const progressSum = + groups.completed.reduce((acc, folder) => acc + folder.progress, 0) + + groups.inProgress.reduce((acc, folder) => acc + folder.progress, 0) + + groups.pending.reduce((acc, folder) => acc + folder.progress, 0); + + const progressPct = total > 0 ? Math.round(progressSum / total) : 0; return { total, completedCount, @@ -94,7 +120,7 @@ const FolderManagementCard: React.FC = () => { taggedCount, progressPct, }; - }, [folders.length, groups]); + }, [groups]); const [showCompleted, setShowCompleted] = useState(true); const [showInProgress, setShowInProgress] = useState(true); @@ -329,8 +355,8 @@ const FolderRow: React.FC<{ disableAITaggingPending, deleteFolderPending, }) => { - const percentage = Math.round(taggingStatus?.tagging_percentage ?? 0); - const isComplete = (taggingStatus?.tagging_percentage ?? 0) >= 100; + const percentage = getFolderProgress(folder, taggingStatus); + const isComplete = percentage >= 100; return (