From d5bed8c0047149e84a3f72147f31bfa8eaacfe17 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Wed, 1 Apr 2026 18:00:35 +0200 Subject: [PATCH] Add mobile app subfolder support scaffolding (#1695) --- .../aliasvault/app/vaultstore/models/Item.kt | 4 +- .../vaultstore/repositories/ItemRepository.kt | 84 +++++- .../app/vaultstore/utils/FolderUtils.kt | 166 ++++++++++++ apps/mobile-app/app/(tabs)/items/[id].tsx | 4 + .../app/(tabs)/items/folder/[id].tsx | 174 ++++++++++++- apps/mobile-app/app/(tabs)/items/index.tsx | 40 ++- .../components/folders/FolderBreadcrumb.tsx | 241 ++++++++++++++++++ .../components/folders/FolderSelector.tsx | 119 ++++++++- apps/mobile-app/components/items/ItemCard.tsx | 4 +- apps/mobile-app/ios/VaultModels/Item.swift | 4 +- .../Database/Mappers/ItemMapper.swift | 29 ++- .../Database/Queries/ItemQueries.swift | 6 +- .../Repositories/ItemRepository.swift | 75 +++++- .../ios/VaultStoreKit/Utils/FolderUtils.swift | 161 ++++++++++++ .../mobile-app/utils/db/mappers/ItemMapper.ts | 34 ++- .../utils/db/queries/ItemQueries.ts | 14 +- .../utils/db/repositories/FolderRepository.ts | 113 +++++++- .../utils/db/repositories/ItemRepository.ts | 93 ++++++- 18 files changed, 1273 insertions(+), 92 deletions(-) create mode 100644 apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/utils/FolderUtils.kt create mode 100644 apps/mobile-app/components/folders/FolderBreadcrumb.tsx create mode 100644 apps/mobile-app/ios/VaultStoreKit/Utils/FolderUtils.swift diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/models/Item.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/models/Item.kt index 86abf7a44..97d7f2646 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/models/Item.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/models/Item.kt @@ -11,7 +11,7 @@ import java.util.UUID * @property itemType The type of item (Login, Alias, CreditCard, Note). * @property logo The logo image data in bytes. * @property folderId The ID of the folder containing this item. - * @property folderPath The path to the folder containing this item. + * @property folderPath The folder path as an array of folder names from root to current folder. * @property fields The list of field values for this item. * @property hasPasskey Whether this item has an associated passkey. * @property hasAttachment Whether this item has attachments. @@ -25,7 +25,7 @@ data class Item( val itemType: String, val logo: ByteArray?, val folderId: UUID?, - val folderPath: String?, + val folderPath: List?, val fields: List, val hasPasskey: Boolean, val hasAttachment: Boolean, diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/ItemRepository.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/ItemRepository.kt index 39350a60d..8feb3644b 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/ItemRepository.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/ItemRepository.kt @@ -7,6 +7,7 @@ import net.aliasvault.app.vaultstore.models.FieldKey import net.aliasvault.app.vaultstore.models.FieldType import net.aliasvault.app.vaultstore.models.Item import net.aliasvault.app.vaultstore.models.ItemField +import net.aliasvault.app.vaultstore.utils.FolderUtils import java.util.Calendar import java.util.Date import java.util.TimeZone @@ -33,6 +34,53 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) { // MARK: - Read Operations + /** + * Build folder paths for all folders. + * Returns a map of FolderId -> path array. + * + * @return Map of folder ID to folder path array. + */ + private fun buildFolderPaths(): Map> { + val folderPathMap = mutableMapOf>() + + try { + // Get all folders from database + val folderQuery = "SELECT Id, Name, ParentFolderId FROM Folders WHERE IsDeleted = 0" + val folderResults = executeQuery(folderQuery, emptyArray()) + + if (folderResults.isEmpty()) { + return folderPathMap + } + + // Convert to FolderUtils.Folder format + val folders = folderResults.mapNotNull { row -> + try { + val id = UUID.fromString(row["Id"] as? String ?: return@mapNotNull null) + val name = row["Name"] as? String ?: return@mapNotNull null + val parentFolderId = (row["ParentFolderId"] as? String)?.let { UUID.fromString(it) } + FolderUtils.Folder(id, name, parentFolderId) + } catch (e: Exception) { + Log.e(TAG, "Error parsing folder row", e) + null + } + } + + // Use shared utility to build paths for all folders + for (folder in folders) { + val path = FolderUtils.getFolderPath(folder.id, folders) + if (path.isNotEmpty()) { + folderPathMap[folder.id] = path + } + } + + return folderPathMap + } catch (e: Exception) { + // Folders table may not exist in older vault versions + Log.e(TAG, "Error building folder paths", e) + return folderPathMap + } + } + /** * Fetch all active items (not deleted, not in trash) with their fields. * @@ -46,7 +94,6 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) { i.Name, i.ItemType, i.FolderId, - f.Name as FolderPath, l.FileData as Logo, CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, @@ -55,7 +102,6 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) { i.UpdatedAt FROM Items i LEFT JOIN Logos l ON i.LogoId = l.Id - LEFT JOIN Folders f ON i.FolderId = f.Id WHERE i.IsDeleted = 0 AND i.DeletedAt IS NULL ORDER BY i.CreatedAt DESC """.trimIndent() @@ -63,6 +109,9 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) { val items = mutableListOf() val itemIds = mutableListOf() + // Build folder paths + val folderPaths = buildFolderPaths() + val itemResults = executeQueryWithBlobs(itemQuery, emptyArray()) for (row in itemResults) { try { @@ -70,7 +119,6 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) { val name = row["Name"] as? String val itemType = row["ItemType"] as? String ?: continue val folderId = row["FolderId"] as? String - val folderPath = row["FolderPath"] as? String val logo = row["Logo"] as? ByteArray val hasPasskey = (row["HasPasskey"] as? Long) == 1L val hasAttachment = (row["HasAttachment"] as? Long) == 1L @@ -78,12 +126,16 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) { val createdAt = DateHelpers.parseDateString(row["CreatedAt"] as? String ?: "") ?: MIN_DATE val updatedAt = DateHelpers.parseDateString(row["UpdatedAt"] as? String ?: "") ?: MIN_DATE + // Get folder path if item is in a folder + val folderUuid = folderId?.let { UUID.fromString(it) } + val folderPath = folderUuid?.let { folderPaths[it] } + val item = Item( id = UUID.fromString(idString), name = name, itemType = itemType, logo = logo, - folderId = folderId?.let { UUID.fromString(it) }, + folderId = folderUuid, folderPath = folderPath, fields = emptyList(), // Will be populated below hasPasskey = hasPasskey, @@ -193,7 +245,6 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) { i.Name, i.ItemType, i.FolderId, - f.Name as FolderPath, l.FileData as Logo, CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, @@ -202,19 +253,20 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) { i.UpdatedAt FROM Items i LEFT JOIN Logos l ON i.LogoId = l.Id - LEFT JOIN Folders f ON i.FolderId = f.Id WHERE i.Id = ? AND i.IsDeleted = 0 AND i.DeletedAt IS NULL """.trimIndent() val itemResults = executeQueryWithBlobs(itemQuery, arrayOf(itemId.uppercase())) val row = itemResults.firstOrNull() ?: return null + // Build folder paths + val folderPaths = buildFolderPaths() + return try { val idString = row["Id"] as? String ?: return null val name = row["Name"] as? String val itemType = row["ItemType"] as? String ?: return null val folderId = row["FolderId"] as? String - val folderPath = row["FolderPath"] as? String val logo = row["Logo"] as? ByteArray val hasPasskey = (row["HasPasskey"] as? Long) == 1L val hasAttachment = (row["HasAttachment"] as? Long) == 1L @@ -222,6 +274,10 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) { val createdAt = DateHelpers.parseDateString(row["CreatedAt"] as? String ?: "") ?: MIN_DATE val updatedAt = DateHelpers.parseDateString(row["UpdatedAt"] as? String ?: "") ?: MIN_DATE + // Get folder path if item is in a folder + val folderUuid = folderId?.let { UUID.fromString(it) } + val folderPath = folderUuid?.let { folderPaths[it] } + // Get field values for this item val fieldQuery = """ SELECT @@ -282,7 +338,7 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) { name = name, itemType = itemType, logo = logo, - folderId = folderId?.let { UUID.fromString(it) }, + folderId = folderUuid, folderPath = folderPath, fields = fields, hasPasskey = hasPasskey, @@ -332,7 +388,6 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) { i.Name, i.ItemType, i.FolderId, - f.Name as FolderPath, l.FileData as Logo, CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, @@ -342,7 +397,6 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) { i.DeletedAt FROM Items i LEFT JOIN Logos l ON i.LogoId = l.Id - LEFT JOIN Folders f ON i.FolderId = f.Id WHERE i.IsDeleted = 0 AND i.DeletedAt IS NOT NULL ORDER BY i.DeletedAt DESC """.trimIndent() @@ -350,13 +404,15 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) { val items = mutableListOf() val results = executeQueryWithBlobs(query, emptyArray()) + // Build folder paths + val folderPaths = buildFolderPaths() + for (row in results) { try { val idString = row["Id"] as? String ?: continue val name = row["Name"] as? String val itemType = row["ItemType"] as? String ?: continue val folderId = row["FolderId"] as? String - val folderPath = row["FolderPath"] as? String val logo = row["Logo"] as? ByteArray val hasPasskey = (row["HasPasskey"] as? Long) == 1L val hasAttachment = (row["HasAttachment"] as? Long) == 1L @@ -364,13 +420,17 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) { val createdAt = DateHelpers.parseDateString(row["CreatedAt"] as? String ?: "") ?: MIN_DATE val updatedAt = DateHelpers.parseDateString(row["UpdatedAt"] as? String ?: "") ?: MIN_DATE + // Get folder path if item is in a folder + val folderUuid = folderId?.let { UUID.fromString(it) } + val folderPath = folderUuid?.let { folderPaths[it] } + items.add( Item( id = UUID.fromString(idString), name = name, itemType = itemType, logo = logo, - folderId = folderId?.let { UUID.fromString(it) }, + folderId = folderUuid, folderPath = folderPath, fields = emptyList(), // Not loading fields for trash items hasPasskey = hasPasskey, diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/utils/FolderUtils.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/utils/FolderUtils.kt new file mode 100644 index 000000000..43c7f29cd --- /dev/null +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/utils/FolderUtils.kt @@ -0,0 +1,166 @@ +package net.aliasvault.app.vaultstore.utils + +import java.util.UUID + +/** + * Utilities for working with folder hierarchies and trees. + */ +object FolderUtils { + /** + * Maximum allowed folder nesting depth. + * Structure: Root (0) > Level 1 (1) > Level 2 (2) > Level 3 (3) > Level 4 (4). + * Folders at depth 4 cannot have subfolders. + */ + const val MAX_FOLDER_DEPTH = 4 + + /** + * Folder model matching database structure. + * + * @property id The unique identifier of the folder. + * @property name The name of the folder. + * @property parentFolderId The ID of the parent folder (null for root folders). + */ + data class Folder( + val id: UUID, + val name: String, + val parentFolderId: UUID?, + ) + + /** + * Get folder depth in the hierarchy. + * @param folderId The folder ID to check. + * @param folders Flat array of all folders. + * @return Depth (0 = root, 1 = one level deep, etc.) or null if folder not found. + */ + @Suppress("LoopWithTooManyJumpStatements") + fun getFolderDepth(folderId: UUID, folders: List): Int? { + val folder = folders.find { it.id == folderId } ?: return null + + var depth = 0 + var currentId: UUID? = folderId + + // Traverse up to root, counting levels + while (currentId != null) { + val current = folders.find { it.id == currentId } ?: break + if (current.parentFolderId == null) { + break + } + depth++ + currentId = current.parentFolderId + + // Prevent infinite loops + if (depth > MAX_FOLDER_DEPTH) { + break + } + } + + return depth + } + + /** + * Get the full path of folder names from root to the specified folder. + * @param folderId The folder ID. + * @param folders Flat array of all folders. + * @return Array of folder names from root to current folder, or empty array if not found. + */ + fun getFolderPath(folderId: UUID?, folders: List): List { + if (folderId == null) { + return emptyList() + } + + val path = mutableListOf() + var currentId: UUID? = folderId + var iterations = 0 + + // Build path by traversing up to root + while (currentId != null && iterations < MAX_FOLDER_DEPTH + 1) { + val folder = folders.find { it.id == currentId } ?: break + path.add(0, folder.name) // Add to beginning of array + currentId = folder.parentFolderId + iterations++ + } + + return path + } + + /** + * Get the full path of folder IDs from root to the specified folder. + * @param folderId The folder ID. + * @param folders Flat array of all folders. + * @return Array of folder IDs from root to current folder, or empty array if not found. + */ + fun getFolderIdPath(folderId: UUID?, folders: List): List { + if (folderId == null) { + return emptyList() + } + + val path = mutableListOf() + var currentId: UUID? = folderId + var iterations = 0 + + // Build path by traversing up to root + while (currentId != null && iterations < MAX_FOLDER_DEPTH + 1) { + val folder = folders.find { it.id == currentId } ?: break + path.add(0, folder.id) // Add to beginning of array + currentId = folder.parentFolderId + iterations++ + } + + return path + } + + /** + * Format folder path for display with separator. + * @param pathSegments Array of folder names. + * @param separator Separator string (default: " > "). + * @return Formatted folder path string. + */ + fun formatFolderPath(pathSegments: List, separator: String = " > "): String { + return pathSegments.joinToString(separator) + } + + /** + * Check if a folder can have subfolders (not at max depth). + * @param folderId The folder ID to check. + * @param folders Flat array of all folders. + * @return True if folder can have children, false otherwise. + */ + fun canHaveSubfolders(folderId: UUID, folders: List): Boolean { + val depth = getFolderDepth(folderId, folders) + return depth != null && depth < MAX_FOLDER_DEPTH + } + + /** + * Get all descendant folder IDs (children, grandchildren, etc.). + * @param folderId The parent folder ID. + * @param folders Flat array of all folders. + * @return Array of descendant folder IDs. + */ + fun getDescendantFolderIds(folderId: UUID, folders: List): List { + val descendants = mutableListOf() + + fun traverse(parentId: UUID) { + folders + .filter { it.parentFolderId == parentId } + .forEach { child -> + descendants.add(child.id) + traverse(child.id) + } + } + + traverse(folderId) + return descendants + } + + /** + * Get all direct child folder IDs. + * @param parentFolderId The parent folder ID (null for root). + * @param folders Flat array of all folders. + * @return Array of direct child folder IDs. + */ + fun getDirectChildFolderIds(parentFolderId: UUID?, folders: List): List { + return folders + .filter { it.parentFolderId == parentFolderId } + .map { it.id } + } +} diff --git a/apps/mobile-app/app/(tabs)/items/[id].tsx b/apps/mobile-app/app/(tabs)/items/[id].tsx index 3e8df77a4..a87e3ce55 100644 --- a/apps/mobile-app/app/(tabs)/items/[id].tsx +++ b/apps/mobile-app/app/(tabs)/items/[id].tsx @@ -14,6 +14,7 @@ import emitter from '@/utils/EventEmitter'; import { useColors } from '@/hooks/useColorScheme'; import { useNavigationDebounce } from '@/hooks/useNavigationDebounce'; +import { FolderBreadcrumb } from '@/components/folders/FolderBreadcrumb'; import { AliasDetails } from '@/components/items/details/AliasDetails'; import { AttachmentSection } from '@/components/items/details/AttachmentSection'; import { CardDetails } from '@/components/items/details/CardDetails'; @@ -282,6 +283,9 @@ export default function ItemDetailsScreen() : React.ReactNode { return ( + {/* Folder breadcrumb navigation */} + + diff --git a/apps/mobile-app/app/(tabs)/items/folder/[id].tsx b/apps/mobile-app/app/(tabs)/items/folder/[id].tsx index fb8e0e08e..6e0ed86e0 100644 --- a/apps/mobile-app/app/(tabs)/items/folder/[id].tsx +++ b/apps/mobile-app/app/(tabs)/items/folder/[id].tsx @@ -23,7 +23,9 @@ import { useVaultMutate } from '@/hooks/useVaultMutate'; import { useVaultSync } from '@/hooks/useVaultSync'; import { DeleteFolderModal } from '@/components/folders/DeleteFolderModal'; +import { FolderBreadcrumb } from '@/components/folders/FolderBreadcrumb'; import { FolderModal } from '@/components/folders/FolderModal'; +import { FolderPill } from '@/components/folders/FolderPill'; import { ItemCard } from '@/components/items/ItemCard'; import { SortMenu } from '@/components/items/SortMenu'; import { ThemedContainer } from '@/components/themed/ThemedContainer'; @@ -35,6 +37,8 @@ import { useApp } from '@/context/AppContext'; import { useDb } from '@/context/DbContext'; import { useDialog } from '@/context/DialogContext'; +import type { FolderWithCount } from '@/components/folders/FolderPill'; + /** * Filter types for the items list. */ @@ -83,6 +87,8 @@ export default function FolderViewScreen(): React.ReactNode { const [itemsList, setItemsList] = useState([]); const [folder, setFolder] = useState(null); + const [subfolders, setSubfolders] = useState([]); + const [canCreateSubfolder, setCanCreateSubfolder] = useState(false); // No minimum loading delay for folder view since data is already in memory const [isLoadingItems, setIsLoadingItems] = useState(false); const [refreshing, setRefreshing] = useMinDurationLoading(false, 200); @@ -99,6 +105,7 @@ export default function FolderViewScreen(): React.ReactNode { // Folder modals const [showEditFolderModal, setShowEditFolderModal] = useState(false); const [showDeleteFolderModal, setShowDeleteFolderModal] = useState(false); + const [showCreateSubfolderModal, setShowCreateSubfolderModal] = useState(false); const authContext = useApp(); const dbContext = useDb(); @@ -179,7 +186,41 @@ export default function FolderViewScreen(): React.ReactNode { const sortedItems = useSortedItems(filteredItems, sortOrder); /** - * Load items in this folder and folder details. + * Get folder depth in the hierarchy. + */ + function getFolderDepth(folderId: string | null, folders: Folder[]): number | null { + if (!folderId) { + return null; + } + + const folderItem = folders.find(f => f.Id === folderId); + if (!folderItem) { + return null; + } + + let depth = 0; + let currentId: string | null = folderId; + + // Traverse up to root, counting levels + while (currentId) { + const current = folders.find(f => f.Id === currentId); + if (!current || !current.ParentFolderId) { + break; + } + depth++; + currentId = current.ParentFolderId; + + // Prevent infinite loops + if (depth > 4) { + break; + } + } + + return depth; + } + + /** + * Load items in this folder, subfolders, and folder details. */ const loadItems = useCallback(async (): Promise => { if (!folderId) { @@ -199,6 +240,42 @@ export default function FolderViewScreen(): React.ReactNode { // Find this folder const currentFolder = folders.find((f: Folder) => f.Id === folderId); setFolder(currentFolder || null); + + // Get subfolders (direct children only) + const childFolders = folders.filter((f: Folder) => f.ParentFolderId === folderId); + + /** + * Calculate recursive item count for a folder including all subfolders. + * @param parentFolderId - The parent folder ID to count items for + * @returns Total count of items in the folder and all subfolders + */ + const getRecursiveItemCount = (parentFolderId: string): number => { + // Get items directly in this folder + const directItems = items.filter((item: Item) => item.FolderId === parentFolderId); + + // Get all child folders + const children = folders.filter((f: Folder) => f.ParentFolderId === parentFolderId); + + // Recursively count items in child folders + const childItemCount = children.reduce((count, child) => { + return count + getRecursiveItemCount(child.Id); + }, 0); + + return directItems.length + childItemCount; + }; + + const subfoldersWithCounts: FolderWithCount[] = childFolders.map((f) => ({ + id: f.Id, + name: f.Name, + itemCount: getRecursiveItemCount(f.Id), + })); + + setSubfolders(subfoldersWithCounts); + + // Calculate if we can create subfolders (check depth) + const depth = getFolderDepth(folderId, folders); + setCanCreateSubfolder(depth !== null && depth < 4); + setSortOrder(savedSortOrder); setIsLoadingItems(false); } catch (err) { @@ -406,6 +483,31 @@ export default function FolderViewScreen(): React.ReactNode { }); }, [folderId, router, navigate]); + /** + * Handle subfolder click - navigate to subfolder view. + */ + const handleSubfolderClick = useCallback((subfolderId: string) => { + navigate(() => { + router.push(`/(tabs)/items/folder/${subfolderId}`); + HapticsUtility.impact(); + }); + }, [router, navigate]); + + /** + * Create a new subfolder. + */ + const handleCreateSubfolder = useCallback(async (name: string) => { + if (!folderId) { + return; + } + + await executeVaultMutation(async () => { + await dbContext.sqliteClient!.folders.create(name, folderId); + }); + await loadItems(); + setShowCreateSubfolderModal(false); + }, [dbContext.sqliteClient, folderId, executeVaultMutation, loadItems]); + // Header styles (stable, not dependent on colors) - prefixed with _ as styles are inlined in useEffect const _headerStyles = StyleSheet.create({ headerButton: { @@ -583,6 +685,29 @@ export default function FolderViewScreen(): React.ReactNode { color: colors.primarySurfaceText, fontSize: 24, }, + // Subfolder pills styles + folderPillsContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + marginBottom: 16, + }, + newFolderButton: { + alignItems: 'center', + backgroundColor: colors.accentBackground, + borderColor: colors.accentBorder, + borderRadius: 20, + borderStyle: 'dashed', + borderWidth: 1, + flexDirection: 'row', + gap: 6, + paddingHorizontal: 12, + paddingVertical: 8, + }, + newFolderButtonText: { + color: colors.textMuted, + fontSize: 14, + }, }); /** @@ -698,11 +823,49 @@ export default function FolderViewScreen(): React.ReactNode { }; /** - * Render the list header with filter, sort button, and search. + * Render the list header with breadcrumb, subfolders, filter, sort button, and search. */ const renderListHeader = (): React.ReactNode => { return ( + {/* Breadcrumb navigation */} + + + {/* Subfolder pills (shown when not searching) */} + {!searchQuery && subfolders.length > 0 && ( + + {subfolders.map((subfolder) => ( + handleSubfolderClick(subfolder.id)} + /> + ))} + {canCreateSubfolder && ( + setShowCreateSubfolderModal(true)} + > + + {t('items.folders.newFolder')} + + )} + + )} + + {/* Create subfolder button (when no subfolders exist) */} + {!searchQuery && subfolders.length === 0 && canCreateSubfolder && ( + + setShowCreateSubfolderModal(true)} + > + + {t('items.folders.newFolder')} + + + )} + {/* Header row with filter dropdown and sort button */} {/* Filter button */} @@ -856,6 +1019,13 @@ export default function FolderViewScreen(): React.ReactNode { initialName={folder?.Name || ''} mode="edit" /> + setShowCreateSubfolderModal(false)} + onSave={handleCreateSubfolder} + initialName="" + mode="create" + /> setShowDeleteFolderModal(false)} diff --git a/apps/mobile-app/app/(tabs)/items/index.tsx b/apps/mobile-app/app/(tabs)/items/index.tsx index 78235b610..2a02d2905 100644 --- a/apps/mobile-app/app/(tabs)/items/index.tsx +++ b/apps/mobile-app/app/(tabs)/items/index.tsx @@ -181,21 +181,35 @@ export default function ItemsScreen(): React.ReactNode { return []; } - const folderCounts = new Map(); + /** + * Count items per folder (including items in subfolders recursively). + * @param folderId - The folder ID to count items for + * @returns Total count of items in the folder and all subfolders + */ + const getRecursiveItemCount = (folderId: string): number => { + // Get items directly in this folder + const directItems = itemsList.filter((item: Item) => item.FolderId === folderId); - // Count items per folder - itemsList.forEach((item: Item) => { - if (item.FolderId) { - folderCounts.set(item.FolderId, (folderCounts.get(item.FolderId) || 0) + 1); - } - }); + // Get all child folders + const childFolders = folders.filter(f => f.ParentFolderId === folderId); - // Return folders with counts, sorted alphabetically - return folders.map(folder => ({ - id: folder.Id, - name: folder.Name, - itemCount: folderCounts.get(folder.Id) || 0 - })).sort((a, b) => a.name.localeCompare(b.name)); + // Recursively count items in child folders + const childItemCount = childFolders.reduce((count, child) => { + return count + getRecursiveItemCount(child.Id); + }, 0); + + return directItems.length + childItemCount; + }; + + // Return only root-level folders (no parent) with recursive counts, sorted alphabetically + return folders + .filter(folder => !folder.ParentFolderId) // Only root-level folders + .map(folder => ({ + id: folder.Id, + name: folder.Name, + itemCount: getRecursiveItemCount(folder.Id) + })) + .sort((a, b) => a.name.localeCompare(b.name)); }, [folders, itemsList, searchQuery]); /** diff --git a/apps/mobile-app/components/folders/FolderBreadcrumb.tsx b/apps/mobile-app/components/folders/FolderBreadcrumb.tsx new file mode 100644 index 000000000..9cc22f8fd --- /dev/null +++ b/apps/mobile-app/components/folders/FolderBreadcrumb.tsx @@ -0,0 +1,241 @@ +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import { useRouter } from 'expo-router'; +import React, { useMemo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; + +import type { Folder } from '@/utils/db/repositories/FolderRepository'; +import { useColors } from '@/hooks/useColorScheme'; +import { useDb } from '@/context/DbContext'; + +type Breadcrumb = { + name: string; + id: string; +}; + +type FolderBreadcrumbProps = { + /** + * The ID of the current folder to show breadcrumbs for. + * If null/undefined, no breadcrumbs are shown. + */ + folderId: string | null | undefined; + /** + * Optional root label for the first breadcrumb. + * Defaults to 'items.title' translation key. + */ + rootLabel?: string; + /** + * Whether to exclude the current folder from breadcrumbs. + * Useful when the folder name is already shown in the header. + * Defaults to false. + */ + excludeCurrentFolder?: boolean; +}; + +/** + * Get the full path of folder names from root to the specified folder. + * @param folderId - The folder ID + * @param folders - Flat array of all folders + * @returns Array of folder names from root to current folder, or empty array if not found + */ +function getFolderPath(folderId: string | null, folders: Folder[]): string[] { + if (!folderId) { + return []; + } + + const path: string[] = []; + let currentId: string | null = folderId; + let iterations = 0; + + // Build path by traversing up to root + while (currentId && iterations < 5) { + const folder = folders.find(f => f.Id === currentId); + if (!folder) { + break; + } + path.unshift(folder.Name); // Add to beginning of array + currentId = folder.ParentFolderId; + iterations++; + } + + return path; +} + +/** + * Get the full path of folder IDs from root to the specified folder. + * @param folderId - The folder ID + * @param folders - Flat array of all folders + * @returns Array of folder IDs from root to current folder, or empty array if not found + */ +function getFolderIdPath(folderId: string | null, folders: Folder[]): string[] { + if (!folderId) { + return []; + } + + const path: string[] = []; + let currentId: string | null = folderId; + let iterations = 0; + + // Build path by traversing up to root + while (currentId && iterations < 5) { + const folder = folders.find(f => f.Id === currentId); + if (!folder) { + break; + } + path.unshift(folder.Id); // Add to beginning of array + currentId = folder.ParentFolderId; + iterations++; + } + + return path; +} + +/** + * Displays a breadcrumb navigation trail for folder hierarchy. + * Shows the path to the current location, with optional exclusion of current folder. + * Example: "Items > Work > Projects > Client A" + */ +export const FolderBreadcrumb: React.FC = ({ + folderId, + rootLabel, + excludeCurrentFolder = false, +}) => { + const { t } = useTranslation(); + const router = useRouter(); + const dbContext = useDb(); + const colors = useColors(); + + /** + * Compute breadcrumb trail based on current folder. + * Optionally excludes the current folder (to avoid duplication with page title). + */ + const breadcrumbs = useMemo((): Breadcrumb[] => { + if (!folderId || !dbContext?.sqliteClient) { + return []; + } + + try { + const allFolders = dbContext.sqliteClient.folders.getAll(); + + // Ensure allFolders is an array + if (!Array.isArray(allFolders)) { + console.warn('folders.getAll() did not return an array:', allFolders); + return []; + } + + const folderNames = getFolderPath(folderId, allFolders); + const folderIds = getFolderIdPath(folderId, allFolders); + let fullPath = folderNames.map((name, index) => ({ + name, + id: folderIds[index] + })); + + // If requested, exclude the current folder from breadcrumbs + if (excludeCurrentFolder && fullPath.length > 0) { + fullPath = fullPath.slice(0, -1); // Remove last item (current folder) + } + + return fullPath; + } catch (error) { + console.error('Error building breadcrumbs:', error); + return []; + } + }, [folderId, dbContext?.sqliteClient, excludeCurrentFolder]); + + /** + * Handle breadcrumb navigation. + */ + const handleBreadcrumbClick = useCallback((folderId: string) => { + router.push(`/(tabs)/items/folder/${folderId}`); + }, [router]); + + /** + * Handle root breadcrumb click (navigate to items list). + */ + const handleRootClick = useCallback(() => { + router.push('/(tabs)/items'); + }, [router]); + + // Don't render anything if no folderId provided + if (!folderId || breadcrumbs.length === 0) { + return null; + } + + const rootLabelText = rootLabel ?? t('items.title'); + + const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + marginBottom: 12, + paddingHorizontal: 2, + }, + rootButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingVertical: 4, + paddingHorizontal: 6, + borderRadius: 4, + }, + rootText: { + fontSize: 13, + color: colors.textMuted, + fontWeight: '500', + }, + chevron: { + marginHorizontal: 4, + color: colors.textMuted, + }, + breadcrumbButton: { + paddingVertical: 4, + paddingHorizontal: 6, + borderRadius: 4, + }, + breadcrumbText: { + fontSize: 13, + color: colors.textMuted, + }, + }); + + return ( + + {/* Root breadcrumb (Items) */} + + + {rootLabelText} + + + {/* Folder breadcrumbs */} + {breadcrumbs.map((crumb) => ( + + + handleBreadcrumbClick(crumb.id)} + style={styles.breadcrumbButton} + activeOpacity={0.6} + > + + {crumb.name} + + + + ))} + + ); +}; + +export default FolderBreadcrumb; diff --git a/apps/mobile-app/components/folders/FolderSelector.tsx b/apps/mobile-app/components/folders/FolderSelector.tsx index b73aaae4b..cfa6c1d49 100644 --- a/apps/mobile-app/components/folders/FolderSelector.tsx +++ b/apps/mobile-app/components/folders/FolderSelector.tsx @@ -15,6 +15,15 @@ import { ModalWrapper } from '@/components/common/ModalWrapper'; type Folder = { Id: string; Name: string; + ParentFolderId?: string | null; + Weight?: number; +}; + +type FolderTreeNode = Folder & { + children: FolderTreeNode[]; + depth: number; + path: string[]; + indentedName: string; }; interface IFolderSelectorProps { @@ -38,8 +47,109 @@ export const FolderSelector: React.FC = ({ const colors = useColors(); const [showModal, setShowModal] = useState(false); + /** + * Build a hierarchical tree from flat array of folders. + */ + const buildFolderTree = useCallback((folders: Folder[]): FolderTreeNode[] => { + const folderMap = new Map(); + + // Initialize all folders as tree nodes + folders.forEach(folder => { + folderMap.set(folder.Id, { + ...folder, + children: [], + depth: 0, + path: [], + indentedName: folder.Name, + }); + }); + + // Build the tree structure + const rootFolders: FolderTreeNode[] = []; + + folders.forEach(folder => { + const node = folderMap.get(folder.Id)!; + + if (!folder.ParentFolderId) { + // Root folder + node.depth = 0; + node.path = [folder.Id]; + node.indentedName = folder.Name; + rootFolders.push(node); + } else { + // Child folder + const parent = folderMap.get(folder.ParentFolderId); + if (parent) { + node.depth = parent.depth + 1; + node.path = [...parent.path, folder.Id]; + node.indentedName = ' '.repeat(node.depth) + folder.Name; + parent.children.push(node); + } else { + // Parent not found - treat as root + node.depth = 0; + node.path = [folder.Id]; + node.indentedName = folder.Name; + rootFolders.push(node); + } + } + }); + + return rootFolders; + }, []); + + /** + * Flatten folder tree for display. + */ + const flattenTree = useCallback((tree: FolderTreeNode[]): FolderTreeNode[] => { + const result: FolderTreeNode[] = []; + + const traverse = (nodes: FolderTreeNode[]): void => { + nodes.forEach(node => { + result.push(node); + traverse(node.children); + }); + }; + + traverse(tree); + return result; + }, []); + + const folderTree = buildFolderTree(folders); + const flatFolders = flattenTree(folderTree); + const selectedFolder = folders.find(f => f.Id === selectedFolderId); + /** + * Get folder path for display in button. + */ + const getSelectedFolderPath = useCallback((): string => { + if (!selectedFolderId) { + return t('items.folders.noFolder'); + } + + const folder = flatFolders.find(f => f.Id === selectedFolderId); + if (!folder) { + return selectedFolder?.Name || t('items.folders.noFolder'); + } + + // Build path from root to current folder + const pathNames: string[] = []; + let currentId: string | null = selectedFolderId; + let iterations = 0; + + while (currentId && iterations < 5) { + const current = folders.find(f => f.Id === currentId); + if (!current) { + break; + } + pathNames.unshift(current.Name); + currentId = current.ParentFolderId || null; + iterations++; + } + + return pathNames.join(' > '); + }, [selectedFolderId, selectedFolder, flatFolders, folders, t]); + /** * Handle folder selection. */ @@ -155,8 +265,8 @@ export const FolderSelector: React.FC = ({ )} - {/* Folder options */} - {folders.map(folder => ( + {/* Folder options (hierarchical) */} + {flatFolders.map(folder => ( = ({ ]} onPress={() => handleSelectFolder(folder.Id)} > + 0 ? "folder" : "folder-open"} size={22} color={selectedFolderId === folder.Id ? colors.tint : colors.textMuted} /> @@ -202,7 +313,7 @@ export const FolderSelector: React.FC = ({ color={selectedFolderId ? colors.tint : colors.textMuted} /> - {selectedFolder ? selectedFolder.Name : t('items.folders.noFolder')} + {getSelectedFolderPath()} - {showFolderPath && item.FolderPath && ( - {item.FolderPath} > + {showFolderPath && item.FolderPath && item.FolderPath.length > 0 && ( + {item.FolderPath.join(' > ')} > )} {getItemName(item)} diff --git a/apps/mobile-app/ios/VaultModels/Item.swift b/apps/mobile-app/ios/VaultModels/Item.swift index 1c29a631a..ac1f2c8be 100644 --- a/apps/mobile-app/ios/VaultModels/Item.swift +++ b/apps/mobile-app/ios/VaultModels/Item.swift @@ -7,7 +7,7 @@ public struct Item: Codable, Hashable, Equatable { public let itemType: String public let logo: Data? public let folderId: UUID? - public let folderPath: String? + public let folderPath: [String]? public let fields: [ItemField] public let hasPasskey: Bool public let hasAttachment: Bool @@ -21,7 +21,7 @@ public struct Item: Codable, Hashable, Equatable { itemType: String, logo: Data?, folderId: UUID?, - folderPath: String?, + folderPath: [String]?, fields: [ItemField], hasPasskey: Bool, hasAttachment: Bool, diff --git a/apps/mobile-app/ios/VaultStoreKit/Database/Mappers/ItemMapper.swift b/apps/mobile-app/ios/VaultStoreKit/Database/Mappers/ItemMapper.swift index acb975039..bcd0c82cb 100644 --- a/apps/mobile-app/ios/VaultStoreKit/Database/Mappers/ItemMapper.swift +++ b/apps/mobile-app/ios/VaultStoreKit/Database/Mappers/ItemMapper.swift @@ -9,7 +9,6 @@ public struct ItemRow { public let name: String? public let itemType: String public let folderId: String? - public let folderPath: String? public let logo: Data? public let hasPasskey: Bool public let hasAttachment: Bool @@ -31,7 +30,6 @@ public struct ItemRow { self.name = row["Name"] as? String self.itemType = itemType self.folderId = row["FolderId"] as? String - self.folderPath = row["FolderPath"] as? String // Handle logo data - can be base64 string or Blob if let logoBase64 = row["Logo"] as? String { @@ -98,8 +96,13 @@ public struct ItemMapper { /// - Parameters: /// - row: Raw item row from database /// - fields: Processed fields for this item + /// - folderPath: Computed folder path array (optional) /// - Returns: Item object - public static func mapRow(_ row: ItemRow, fields: [ItemField] = []) -> Item? { + public static func mapRow( + _ row: ItemRow, + fields: [ItemField] = [], + folderPath: [String]? = nil + ) -> Item? { guard let createdAt = DateHelpers.parseDateString(row.createdAt), let updatedAt = DateHelpers.parseDateString(row.updatedAt) else { return nil @@ -111,7 +114,7 @@ public struct ItemMapper { itemType: row.itemType, logo: row.logo, folderId: row.folderId.flatMap { UUID(uuidString: $0) }, - folderPath: row.folderPath, + folderPath: folderPath, fields: fields, hasPasskey: row.hasPasskey, hasAttachment: row.hasAttachment, @@ -125,14 +128,19 @@ public struct ItemMapper { /// - Parameters: /// - rows: Raw item rows from database /// - fieldsByItem: Dictionary of ItemId to array of fields + /// - folderPathsByFolderId: Dictionary of FolderId to folder path array (optional) /// - Returns: Array of Item objects public static func mapRows( _ rows: [ItemRow], - fieldsByItem: [String: [ItemField]] + fieldsByItem: [String: [ItemField]], + folderPathsByFolderId: [UUID: [String]] = [:] ) -> [Item] { return rows.compactMap { row in let fields = fieldsByItem[row.id] ?? [] - return mapRow(row, fields: fields) + let folderPath = row.folderId + .flatMap { UUID(uuidString: $0) } + .flatMap { folderPathsByFolderId[$0] } + return mapRow(row, fields: fields, folderPath: folderPath) } } @@ -140,8 +148,13 @@ public struct ItemMapper { /// - Parameters: /// - row: Raw item row with DeletedAt /// - fields: Processed fields for this item + /// - folderPath: Computed folder path array (optional) /// - Returns: Item object (deletedAt stored as extension or separate property if needed) - public static func mapDeletedItemRow(_ row: ItemRow, fields: [ItemField] = []) -> Item? { - return mapRow(row, fields: fields) + public static func mapDeletedItemRow( + _ row: ItemRow, + fields: [ItemField] = [], + folderPath: [String]? = nil + ) -> Item? { + return mapRow(row, fields: fields, folderPath: folderPath) } } diff --git a/apps/mobile-app/ios/VaultStoreKit/Database/Queries/ItemQueries.swift b/apps/mobile-app/ios/VaultStoreKit/Database/Queries/ItemQueries.swift index 0687be476..54a434dff 100644 --- a/apps/mobile-app/ios/VaultStoreKit/Database/Queries/ItemQueries.swift +++ b/apps/mobile-app/ios/VaultStoreKit/Database/Queries/ItemQueries.swift @@ -5,14 +5,13 @@ import Foundation /// Mirrors the React Native implementation. public struct ItemQueries { /// Base SELECT for items with common fields. - /// Includes LEFT JOIN to Logos and Folders, and subqueries for HasPasskey/HasAttachment/HasTotp. + /// Includes LEFT JOIN to Logos and subqueries for HasPasskey/HasAttachment/HasTotp. public static let baseSelect = """ SELECT DISTINCT i.Id, i.Name, i.ItemType, i.FolderId, - f.Name as FolderPath, l.FileData as Logo, CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, @@ -21,7 +20,6 @@ public struct ItemQueries { i.UpdatedAt FROM Items i LEFT JOIN Logos l ON i.LogoId = l.Id - LEFT JOIN Folders f ON i.FolderId = f.Id """ /// Get all active items (not deleted, not in trash). @@ -38,7 +36,6 @@ public struct ItemQueries { i.Name, i.ItemType, i.FolderId, - f.Name as FolderPath, l.FileData as Logo, CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, @@ -47,7 +44,6 @@ public struct ItemQueries { i.UpdatedAt FROM Items i LEFT JOIN Logos l ON i.LogoId = l.Id - LEFT JOIN Folders f ON i.FolderId = f.Id WHERE i.Id = ? AND i.IsDeleted = 0 """ diff --git a/apps/mobile-app/ios/VaultStoreKit/Database/Repositories/ItemRepository.swift b/apps/mobile-app/ios/VaultStoreKit/Database/Repositories/ItemRepository.swift index 04649c507..db1edd7b3 100644 --- a/apps/mobile-app/ios/VaultStoreKit/Database/Repositories/ItemRepository.swift +++ b/apps/mobile-app/ios/VaultStoreKit/Database/Repositories/ItemRepository.swift @@ -7,6 +7,54 @@ public class ItemRepository: BaseRepository { // MARK: - Read Operations + /// Build folder paths for all folders. + /// Returns a map of FolderId -> path array. + /// - Returns: Dictionary of folder ID to folder path array + private func buildFolderPaths() throws -> [UUID: [String]] { + var folderPathMap: [UUID: [String]] = [:] + + do { + // Get all folders from database + let folderQuery = "SELECT Id, Name, ParentFolderId FROM Folders WHERE IsDeleted = 0" + let folderResults = try client.executeQuery(folderQuery, params: []) + + if folderResults.isEmpty { + return folderPathMap + } + + // Convert to FolderUtils.Folder format + let folders = folderResults.compactMap { row -> FolderUtils.Folder? in + guard let idString = row["Id"] as? String, + let id = UUID(uuidString: idString), + let name = row["Name"] as? String else { + return nil + } + + let parentFolderId: UUID? = { + guard let parentIdString = row["ParentFolderId"] as? String else { + return nil + } + return UUID(uuidString: parentIdString) + }() + + return FolderUtils.Folder(id: id, name: name, parentFolderId: parentFolderId) + } + + // Use shared utility to build paths for all folders + for folder in folders { + let path = FolderUtils.getFolderPath(folderId: folder.id, folders: folders) + if !path.isEmpty { + folderPathMap[folder.id] = path + } + } + + return folderPathMap + } catch { + // Folders table may not exist in older vault versions + return folderPathMap + } + } + /// Fetch all active items (not deleted, not in trash) with their fields. /// - Returns: Array of Item objects public func getAll() throws -> [Item] { @@ -27,8 +75,11 @@ public class ItemRepository: BaseRepository { // 3. Process fields into a dictionary by ItemId let fieldsByItem = FieldMapper.processFieldRows(fieldRows) - // 4. Map rows to Item objects - return ItemMapper.mapRows(itemRows, fieldsByItem: fieldsByItem) + // 4. Build folder paths + let folderPaths = try buildFolderPaths() + + // 5. Map rows to Item objects + return ItemMapper.mapRows(itemRows, fieldsByItem: fieldsByItem, folderPathsByFolderId: folderPaths) } /// Fetch a single item by ID with its fields. @@ -46,8 +97,16 @@ public class ItemRepository: BaseRepository { let fieldRows = fieldResults.compactMap { SingleItemFieldRow(from: $0) } let fields = FieldMapper.processFieldRowsForSingleItem(fieldRows) - // 3. Map to Item object - return ItemMapper.mapRow(itemRow, fields: fields) + // 3. Build folder paths + let folderPaths = try buildFolderPaths() + + // 4. Get folder path if item is in a folder + let folderPath = itemRow.folderId + .flatMap { UUID(uuidString: $0) } + .flatMap { folderPaths[$0] } + + // 5. Map to Item object + return ItemMapper.mapRow(itemRow, fields: fields, folderPath: folderPath) } /// Fetch all unique email addresses from field values. @@ -77,8 +136,14 @@ public class ItemRepository: BaseRepository { let fieldRows = fieldResults.compactMap { FieldRow(from: $0) } let fieldsByItem = FieldMapper.processFieldRows(fieldRows) + // Build folder paths + let folderPaths = try buildFolderPaths() + return itemRows.compactMap { row in - ItemMapper.mapDeletedItemRow(row, fields: fieldsByItem[row.id] ?? []) + let folderPath = row.folderId + .flatMap { UUID(uuidString: $0) } + .flatMap { folderPaths[$0] } + return ItemMapper.mapDeletedItemRow(row, fields: fieldsByItem[row.id] ?? [], folderPath: folderPath) } } diff --git a/apps/mobile-app/ios/VaultStoreKit/Utils/FolderUtils.swift b/apps/mobile-app/ios/VaultStoreKit/Utils/FolderUtils.swift new file mode 100644 index 000000000..0fa702cef --- /dev/null +++ b/apps/mobile-app/ios/VaultStoreKit/Utils/FolderUtils.swift @@ -0,0 +1,161 @@ +import Foundation + +/// Utilities for working with folder hierarchies and trees. +public enum FolderUtils { + /// Maximum allowed folder nesting depth. + /// Structure: Root (0) > Level 1 (1) > Level 2 (2) > Level 3 (3) > Level 4 (4). + /// Folders at depth 4 cannot have subfolders. + public static let maxFolderDepth = 4 + + /// Simplified folder model for utility functions. + public struct Folder { + public let id: UUID + public let name: String + public let parentFolderId: UUID? + + public init(id: UUID, name: String, parentFolderId: UUID?) { + self.id = id + self.name = name + self.parentFolderId = parentFolderId + } + } + + /// Get folder depth in the hierarchy. + /// - Parameters: + /// - folderId: The folder ID to check. + /// - folders: Flat array of all folders. + /// - Returns: Depth (0 = root, 1 = one level deep, etc.) or nil if folder not found. + public static func getFolderDepth(folderId: UUID, folders: [Folder]) -> Int? { + guard folders.contains(where: { $0.id == folderId }) else { + return nil + } + + var depth = 0 + var currentId: UUID? = folderId + + // Traverse up to root, counting levels + while let id = currentId { + guard let current = folders.first(where: { $0.id == id }) else { + break + } + guard let parentId = current.parentFolderId else { + break + } + depth += 1 + currentId = parentId + + // Prevent infinite loops + if depth > maxFolderDepth { + break + } + } + + return depth + } + + /// Get the full path of folder names from root to the specified folder. + /// - Parameters: + /// - folderId: The folder ID. + /// - folders: Flat array of all folders. + /// - Returns: Array of folder names from root to current folder, or empty array if not found. + public static func getFolderPath(folderId: UUID?, folders: [Folder]) -> [String] { + guard let folderId = folderId else { + return [] + } + + var path: [String] = [] + var currentId: UUID? = folderId + var iterations = 0 + + // Build path by traversing up to root + while let id = currentId, iterations < maxFolderDepth + 1 { + guard let folder = folders.first(where: { $0.id == id }) else { + break + } + path.insert(folder.name, at: 0) // Add to beginning of array + currentId = folder.parentFolderId + iterations += 1 + } + + return path + } + + /// Get the full path of folder IDs from root to the specified folder. + /// - Parameters: + /// - folderId: The folder ID. + /// - folders: Flat array of all folders. + /// - Returns: Array of folder IDs from root to current folder, or empty array if not found. + public static func getFolderIdPath(folderId: UUID?, folders: [Folder]) -> [UUID] { + guard let folderId = folderId else { + return [] + } + + var path: [UUID] = [] + var currentId: UUID? = folderId + var iterations = 0 + + // Build path by traversing up to root + while let id = currentId, iterations < maxFolderDepth + 1 { + guard let folder = folders.first(where: { $0.id == id }) else { + break + } + path.insert(folder.id, at: 0) // Add to beginning of array + currentId = folder.parentFolderId + iterations += 1 + } + + return path + } + + /// Format folder path for display with separator. + /// - Parameters: + /// - pathSegments: Array of folder names. + /// - separator: Separator string (default: " > "). + /// - Returns: Formatted folder path string. + public static func formatFolderPath(pathSegments: [String], separator: String = " > ") -> String { + return pathSegments.joined(separator: separator) + } + + /// Check if a folder can have subfolders (not at max depth). + /// - Parameters: + /// - folderId: The folder ID to check. + /// - folders: Flat array of all folders. + /// - Returns: True if folder can have children, false otherwise. + public static func canHaveSubfolders(folderId: UUID, folders: [Folder]) -> Bool { + guard let depth = getFolderDepth(folderId: folderId, folders: folders) else { + return false + } + return depth < maxFolderDepth + } + + /// Get all descendant folder IDs (children, grandchildren, etc.). + /// - Parameters: + /// - folderId: The parent folder ID. + /// - folders: Flat array of all folders. + /// - Returns: Array of descendant folder IDs. + public static func getDescendantFolderIds(folderId: UUID, folders: [Folder]) -> [UUID] { + var descendants: [UUID] = [] + + func traverse(parentId: UUID) { + let children = folders.filter { $0.parentFolderId == parentId } + for child in children { + descendants.append(child.id) + traverse(parentId: child.id) + } + } + + traverse(parentId: folderId) + return descendants + } + + /// Get all direct child folder IDs. + /// - Parameters: + /// - parentFolderId: The parent folder ID (nil for root). + /// - folders: Flat array of all folders. + /// - Returns: Array of direct child folder IDs. + public static func getDirectChildFolderIds(parentFolderId: UUID?, folders: [Folder]) -> [UUID] { + return folders + .filter { $0.parentFolderId == parentFolderId } + .map { $0.id } + } +} diff --git a/apps/mobile-app/utils/db/mappers/ItemMapper.ts b/apps/mobile-app/utils/db/mappers/ItemMapper.ts index 28a2692fe..aeb1a38f6 100644 --- a/apps/mobile-app/utils/db/mappers/ItemMapper.ts +++ b/apps/mobile-app/utils/db/mappers/ItemMapper.ts @@ -13,7 +13,6 @@ export type ItemRow = { Name: string; ItemType: string; FolderId: string | null; - FolderPath: string | null; Logo: Uint8Array | null; HasPasskey: number; HasAttachment: number; @@ -42,12 +41,14 @@ export class ItemMapper { * @param row - Raw item row from database * @param fields - Processed fields for this item * @param tags - Tags for this item + * @param folderPath - Calculated folder path array (optional) * @returns Item object */ public static mapRow( row: ItemRow, fields: ItemField[] = [], - tags: ItemTagRef[] = [] + tags: ItemTagRef[] = [], + folderPath?: string[] | null ): Item { return { Id: row.Id, @@ -55,7 +56,7 @@ export class ItemMapper { ItemType: row.ItemType as ItemType, Logo: row.Logo ?? undefined, FolderId: row.FolderId, - FolderPath: row.FolderPath || null, + FolderPath: folderPath || undefined, Tags: tags, Fields: fields, HasPasskey: row.HasPasskey === 1, @@ -71,18 +72,27 @@ export class ItemMapper { * @param rows - Raw item rows from database * @param fieldsByItem - Map of ItemId to array of fields * @param tagsByItem - Map of ItemId to array of tags + * @param folderPathsByFolderId - Map of FolderId to folder path array (optional) * @returns Array of Item objects */ public static mapRows( rows: ItemRow[], fieldsByItem: Map, - tagsByItem: Map + tagsByItem: Map, + folderPathsByFolderId?: Map ): Item[] { - return rows.map(row => this.mapRow( - row, - fieldsByItem.get(row.Id) || [], - tagsByItem.get(row.Id) || [] - )); + return rows.map(row => { + const folderPath = row.FolderId && folderPathsByFolderId + ? folderPathsByFolderId.get(row.FolderId) || undefined + : undefined; + + return this.mapRow( + row, + fieldsByItem.get(row.Id) || [], + tagsByItem.get(row.Id) || [], + folderPath + ); + }); } /** @@ -124,11 +134,13 @@ export class ItemMapper { * Map a single item row for recently deleted items (includes DeletedAt). * @param row - Raw item row with DeletedAt * @param fields - Processed fields for this item + * @param folderPath - Calculated folder path array (optional) * @returns Item object with DeletedAt */ public static mapDeletedItemRow( row: ItemRow & { DeletedAt: string }, - fields: ItemField[] = [] + fields: ItemField[] = [], + folderPath?: string[] | null ): ItemWithDeletedAt { return { Id: row.Id, @@ -136,7 +148,7 @@ export class ItemMapper { ItemType: row.ItemType as ItemType, Logo: row.Logo ? new Uint8Array(row.Logo) : undefined, FolderId: row.FolderId, - FolderPath: row.FolderPath, + FolderPath: folderPath || undefined, DeletedAt: row.DeletedAt, HasPasskey: row.HasPasskey === 1, HasAttachment: row.HasAttachment === 1, diff --git a/apps/mobile-app/utils/db/queries/ItemQueries.ts b/apps/mobile-app/utils/db/queries/ItemQueries.ts index 09610284b..7683873ce 100644 --- a/apps/mobile-app/utils/db/queries/ItemQueries.ts +++ b/apps/mobile-app/utils/db/queries/ItemQueries.ts @@ -6,7 +6,7 @@ export class ItemQueries { /** * Base SELECT for items with common fields. - * Includes LEFT JOIN to Logos and Folders, and subqueries for HasPasskey/HasAttachment/HasTotp. + * Includes LEFT JOIN to Logos and subqueries for HasPasskey/HasAttachment/HasTotp. */ public static readonly BASE_SELECT = ` SELECT DISTINCT @@ -14,7 +14,6 @@ export class ItemQueries { i.Name, i.ItemType, i.FolderId, - f.Name as FolderPath, l.FileData as Logo, CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, @@ -22,8 +21,7 @@ export class ItemQueries { i.CreatedAt, i.UpdatedAt FROM Items i - LEFT JOIN Logos l ON i.LogoId = l.Id - LEFT JOIN Folders f ON i.FolderId = f.Id`; + LEFT JOIN Logos l ON i.LogoId = l.Id`; /** * Get all active items (not deleted, not in trash). @@ -42,7 +40,6 @@ export class ItemQueries { i.Name, i.ItemType, i.FolderId, - f.Name as FolderPath, l.FileData as Logo, CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, @@ -51,7 +48,6 @@ export class ItemQueries { i.UpdatedAt FROM Items i LEFT JOIN Logos l ON i.LogoId = l.Id - LEFT JOIN Folders f ON i.FolderId = f.Id WHERE i.Id = ? AND i.IsDeleted = 0`; /** @@ -121,7 +117,6 @@ export class ItemQueries { i.Name, i.ItemType, i.FolderId, - f.Name as FolderPath, l.FileData as Logo, CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, @@ -130,7 +125,6 @@ export class ItemQueries { i.UpdatedAt FROM Items i LEFT JOIN Logos l ON i.LogoId = l.Id - LEFT JOIN Folders f ON i.FolderId = f.Id INNER JOIN FieldValues fv ON fv.ItemId = i.Id WHERE LOWER(fv.Value) = LOWER(?) AND fv.FieldKey = ? @@ -148,17 +142,15 @@ export class ItemQueries { i.Name, i.ItemType, i.FolderId, - f.Name as FolderPath, l.FileData as Logo, CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, - CASE WHEN EXISTS (SELECT 1 FROM TotpCodes tc WHERE tc.ItemId = i.Id AND tc.IsDeleted = 0) THEN 1 ELSE 0 END as HasTotp, + CASE WHEN EXISTS (SELECT 1 FROM TotpCodes tc WHERE tc.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasTotp, i.CreatedAt, i.UpdatedAt, i.DeletedAt FROM Items i LEFT JOIN Logos l ON i.LogoId = l.Id - LEFT JOIN Folders f ON i.FolderId = f.Id WHERE i.IsDeleted = 0 AND i.DeletedAt IS NOT NULL ORDER BY i.DeletedAt DESC`; diff --git a/apps/mobile-app/utils/db/repositories/FolderRepository.ts b/apps/mobile-app/utils/db/repositories/FolderRepository.ts index cd54505af..fee80f0ba 100644 --- a/apps/mobile-app/utils/db/repositories/FolderRepository.ts +++ b/apps/mobile-app/utils/db/repositories/FolderRepository.ts @@ -65,6 +65,32 @@ const FolderQueries = { UpdatedAt = ? WHERE FolderId = ?`, + /** + * Move items to a specific folder. + */ + MOVE_ITEMS_TO_FOLDER: ` + UPDATE Items + SET FolderId = ?, + UpdatedAt = ? + WHERE FolderId = ?`, + + /** + * Update parent folder for child folders. + */ + UPDATE_PARENT_FOLDER: ` + UPDATE Folders + SET ParentFolderId = ?, + UpdatedAt = ? + WHERE ParentFolderId = ?`, + + /** + * Get direct child folder IDs. + */ + GET_CHILD_FOLDER_IDS: ` + SELECT Id + FROM Folders + WHERE ParentFolderId = ? AND IsDeleted = 0`, + /** * Trash items in folder. */ @@ -162,9 +188,35 @@ export class FolderRepository extends BaseRepository { }); } + /** + * Get all child folder IDs recursively. + * @param folderId - The parent folder ID + * @returns Array of all descendant folder IDs + */ + private async getAllChildFolderIds(folderId: string): Promise { + const directChildren = await this.client.executeQuery<{ Id: string }>( + FolderQueries.GET_CHILD_FOLDER_IDS, + [folderId] + ); + + const allChildIds: string[] = []; + + for (const child of directChildren) { + allChildIds.push(child.Id); + // Recursively get all descendants + const descendants = await this.getAllChildFolderIds(child.Id); + allChildIds.push(...descendants); + } + + return allChildIds; + } + /** * Delete a folder (soft delete). - * Note: Items in the folder will have their FolderId set to NULL. + * Handles child folders and items: + * - Items in this folder only are moved to the parent folder (or root if no parent) + * - Items in child folders stay in their respective folders (since child folders are moved to parent) + * - All direct child folders are moved to the parent of the deleted folder * @param folderId - The ID of the folder to delete * @returns The number of rows updated */ @@ -172,8 +224,29 @@ export class FolderRepository extends BaseRepository { return this.withTransaction(async () => { const currentDateTime = this.now(); - // Remove folder reference from all items in this folder - await this.client.executeUpdate(FolderQueries.CLEAR_ITEMS_FOLDER, [ + // Get the parent folder of the folder being deleted + const folder = await this.getById(folderId); + const targetParentId = folder?.ParentFolderId || null; + + // Move only items in this folder to the parent folder (or root if no parent) + if (targetParentId) { + // Has parent: move items to parent folder + await this.client.executeUpdate(FolderQueries.MOVE_ITEMS_TO_FOLDER, [ + targetParentId, + currentDateTime, + folderId + ]); + } else { + // No parent: move items to root (NULL) + await this.client.executeUpdate(FolderQueries.CLEAR_ITEMS_FOLDER, [ + currentDateTime, + folderId + ]); + } + + // Move direct child folders to the parent of the deleted folder + await this.client.executeUpdate(FolderQueries.UPDATE_PARENT_FOLDER, [ + targetParentId, currentDateTime, folderId ]); @@ -188,7 +261,9 @@ export class FolderRepository extends BaseRepository { /** * Delete a folder and all items within it (soft delete both folder and items). - * Items are moved to "Recently Deleted" (trash). + * Recursively handles child folders: + * - All items in this folder and child folders are moved to "Recently Deleted" (trash) + * - All child folders are also deleted * @param folderId - The ID of the folder to delete * @returns The number of items trashed */ @@ -196,20 +271,42 @@ export class FolderRepository extends BaseRepository { return this.withTransaction(async () => { const currentDateTime = this.now(); - // Move all items in this folder to trash and clear FolderId - const itemsDeleted = await this.client.executeUpdate(FolderQueries.TRASH_ITEMS_IN_FOLDER, [ + // Get all child folder IDs recursively + const allChildFolderIds = await this.getAllChildFolderIds(folderId); + + let totalItemsDeleted = 0; + + // Move all items in this folder to trash + totalItemsDeleted += await this.client.executeUpdate(FolderQueries.TRASH_ITEMS_IN_FOLDER, [ currentDateTime, currentDateTime, folderId ]); - // Soft delete the folder + // Move all items in child folders to trash + for (const childFolderId of allChildFolderIds) { + totalItemsDeleted += await this.client.executeUpdate(FolderQueries.TRASH_ITEMS_IN_FOLDER, [ + currentDateTime, + currentDateTime, + childFolderId + ]); + } + + // Soft delete all child folders + for (const childFolderId of allChildFolderIds) { + await this.client.executeUpdate(FolderQueries.SOFT_DELETE, [ + currentDateTime, + childFolderId + ]); + } + + // Soft delete the parent folder await this.client.executeUpdate(FolderQueries.SOFT_DELETE, [ currentDateTime, folderId ]); - return itemsDeleted; + return totalItemsDeleted; }); } diff --git a/apps/mobile-app/utils/db/repositories/ItemRepository.ts b/apps/mobile-app/utils/db/repositories/ItemRepository.ts index ed19fe960..51cf05ec6 100644 --- a/apps/mobile-app/utils/db/repositories/ItemRepository.ts +++ b/apps/mobile-app/utils/db/repositories/ItemRepository.ts @@ -53,6 +53,68 @@ export class ItemRepository extends BaseRepository { public setLogoRepository(logoRepository: LogoRepository): void { this.logoRepository = logoRepository; } + + /** + * Build folder paths for all folders. + * Returns a map of FolderId -> path array. + * @returns Map of folder ID to folder path array + */ + private async buildFolderPaths(): Promise> { + const folderPathMap = new Map(); + + try { + // Check if Folders table exists + if (!await this.tableExists('Folders')) { + return folderPathMap; + } + + // Get all folders from database + const folderQuery = 'SELECT Id, Name, ParentFolderId FROM Folders WHERE IsDeleted = 0'; + const folderResults = await this.client.executeQuery<{ + Id: string; + Name: string; + ParentFolderId: string | null; + }>(folderQuery); + + if (folderResults.length === 0) { + return folderPathMap; + } + + // Helper function to build path for a specific folder + const getFolderPath = (folderId: string): string[] => { + const path: string[] = []; + let currentId: string | null = folderId; + let iterations = 0; + const maxIterations = 10; // Prevent infinite loops + + while (currentId && iterations < maxIterations) { + const folder = folderResults.find(f => f.Id === currentId); + if (!folder) break; + + path.unshift(folder.Name); // Add to beginning + currentId = folder.ParentFolderId; + iterations++; + } + + return path; + }; + + // Build paths for all folders + for (const folder of folderResults) { + const path = getFolderPath(folder.Id); + if (path.length > 0) { + folderPathMap.set(folder.Id, path); + } + } + + return folderPathMap; + } catch (error) { + // Folders table may not exist in older vault versions + console.error('Error building folder paths:', error); + return folderPathMap; + } + } + /** * Fetch all active items (not deleted, not in trash) with their fields and tags. * @returns Array of Item objects @@ -81,8 +143,11 @@ export class ItemRepository extends BaseRepository { tagsByItem = ItemMapper.groupTagsByItem(tagRows); } - // 5. Map rows to Item objects - return ItemMapper.mapRows(itemRows, fieldsByItem, tagsByItem); + // 5. Build folder paths + const folderPaths = await this.buildFolderPaths(); + + // 6. Map rows to Item objects + return ItemMapper.mapRows(itemRows, fieldsByItem, tagsByItem, folderPaths); } /** @@ -117,8 +182,12 @@ export class ItemRepository extends BaseRepository { tags = ItemMapper.mapTagRows(tagRows); } - // 4. Map to Item object - return ItemMapper.mapRow(itemRow, fields, tags); + // 4. Build folder paths and get folder path for this item + const folderPaths = await this.buildFolderPaths(); + const folderPath = itemRow.FolderId ? folderPaths.get(itemRow.FolderId) : undefined; + + // 5. Map to Item object + return ItemMapper.mapRow(itemRow, fields, tags, folderPath); } /** @@ -158,8 +227,12 @@ export class ItemRepository extends BaseRepository { ); const fields = FieldMapper.processFieldRowsForSingleItem(fieldRows); - // 3. Map to Item object - return ItemMapper.mapRow(itemRow, fields, []); + // 3. Build folder paths and get folder path for this item + const folderPaths = await this.buildFolderPaths(); + const folderPath = itemRow.FolderId ? folderPaths.get(itemRow.FolderId) : undefined; + + // 4. Map to Item object + return ItemMapper.mapRow(itemRow, fields, [], folderPath); } /** @@ -181,7 +254,13 @@ export class ItemRepository extends BaseRepository { const fieldRows = await this.client.executeQuery(fieldQuery, itemIds); const fieldsByItem = FieldMapper.processFieldRows(fieldRows); - return itemRows.map(row => ItemMapper.mapDeletedItemRow(row, fieldsByItem.get(row.Id) || [])); + // Build folder paths + const folderPaths = await this.buildFolderPaths(); + + return itemRows.map(row => { + const folderPath = row.FolderId ? folderPaths.get(row.FolderId) : undefined; + return ItemMapper.mapDeletedItemRow(row, fieldsByItem.get(row.Id) || [], folderPath); + }); } /**