Add mobile app subfolder support scaffolding (#1695)

This commit is contained in:
Leendert de Borst
2026-04-01 18:00:35 +02:00
parent b528678900
commit d5bed8c004
18 changed files with 1273 additions and 92 deletions

View File

@@ -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<String>?,
val fields: List<ItemField>,
val hasPasskey: Boolean,
val hasAttachment: Boolean,

View File

@@ -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<UUID, List<String>> {
val folderPathMap = mutableMapOf<UUID, List<String>>()
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<Item>()
val itemIds = mutableListOf<String>()
// 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<Item>()
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,

View File

@@ -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<Folder>): 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<Folder>): List<String> {
if (folderId == null) {
return emptyList()
}
val path = mutableListOf<String>()
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<Folder>): List<UUID> {
if (folderId == null) {
return emptyList()
}
val path = mutableListOf<UUID>()
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<String>, 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<Folder>): 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<Folder>): List<UUID> {
val descendants = mutableListOf<UUID>()
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<Folder>): List<UUID> {
return folders
.filter { it.parentFolderId == parentFolderId }
.map { it.id }
}
}

View File

@@ -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 (
<ThemedContainer>
<ThemedScrollView>
{/* Folder breadcrumb navigation */}
<FolderBreadcrumb folderId={item.FolderId} />
<ThemedView style={styles.header}>
<ItemIcon item={item} style={styles.logo} />
<View style={styles.headerText}>

View File

@@ -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<Item[]>([]);
const [folder, setFolder] = useState<Folder | null>(null);
const [subfolders, setSubfolders] = useState<FolderWithCount[]>([]);
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<void> => {
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 (
<ThemedView>
{/* Breadcrumb navigation */}
<FolderBreadcrumb folderId={folderId} excludeCurrentFolder={true} />
{/* Subfolder pills (shown when not searching) */}
{!searchQuery && subfolders.length > 0 && (
<View style={styles.folderPillsContainer}>
{subfolders.map((subfolder) => (
<FolderPill
key={subfolder.id}
folder={subfolder}
onPress={() => handleSubfolderClick(subfolder.id)}
/>
))}
{canCreateSubfolder && (
<TouchableOpacity
style={styles.newFolderButton}
onPress={() => setShowCreateSubfolderModal(true)}
>
<MaterialIcons name="create-new-folder" size={16} color={colors.textMuted} />
<Text style={styles.newFolderButtonText}>{t('items.folders.newFolder')}</Text>
</TouchableOpacity>
)}
</View>
)}
{/* Create subfolder button (when no subfolders exist) */}
{!searchQuery && subfolders.length === 0 && canCreateSubfolder && (
<View style={styles.folderPillsContainer}>
<TouchableOpacity
style={styles.newFolderButton}
onPress={() => setShowCreateSubfolderModal(true)}
>
<MaterialIcons name="create-new-folder" size={16} color={colors.textMuted} />
<Text style={styles.newFolderButtonText}>{t('items.folders.newFolder')}</Text>
</TouchableOpacity>
</View>
)}
{/* Header row with filter dropdown and sort button */}
<View style={styles.headerRow}>
{/* Filter button */}
@@ -856,6 +1019,13 @@ export default function FolderViewScreen(): React.ReactNode {
initialName={folder?.Name || ''}
mode="edit"
/>
<FolderModal
isOpen={showCreateSubfolderModal}
onClose={() => setShowCreateSubfolderModal(false)}
onSave={handleCreateSubfolder}
initialName=""
mode="create"
/>
<DeleteFolderModal
isOpen={showDeleteFolderModal}
onClose={() => setShowDeleteFolderModal(false)}

View File

@@ -181,21 +181,35 @@ export default function ItemsScreen(): React.ReactNode {
return [];
}
const folderCounts = new Map<string, number>();
/**
* 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]);
/**

View File

@@ -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<FolderBreadcrumbProps> = ({
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 (
<View style={styles.container}>
{/* Root breadcrumb (Items) */}
<TouchableOpacity
onPress={handleRootClick}
style={styles.rootButton}
activeOpacity={0.6}
>
<MaterialIcons name="home" size={14} color={colors.textMuted} />
<Text style={styles.rootText}>{rootLabelText}</Text>
</TouchableOpacity>
{/* Folder breadcrumbs */}
{breadcrumbs.map((crumb) => (
<React.Fragment key={crumb.id}>
<MaterialIcons
name="chevron-right"
size={14}
style={styles.chevron}
/>
<TouchableOpacity
onPress={() => handleBreadcrumbClick(crumb.id)}
style={styles.breadcrumbButton}
activeOpacity={0.6}
>
<Text
style={styles.breadcrumbText}
numberOfLines={1}
ellipsizeMode="middle"
>
{crumb.name}
</Text>
</TouchableOpacity>
</React.Fragment>
))}
</View>
);
};
export default FolderBreadcrumb;

View File

@@ -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<IFolderSelectorProps> = ({
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<string, FolderTreeNode>();
// 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<IFolderSelectorProps> = ({
)}
</TouchableOpacity>
{/* Folder options */}
{folders.map(folder => (
{/* Folder options (hierarchical) */}
{flatFolders.map(folder => (
<TouchableOpacity
key={folder.Id}
style={[
@@ -165,8 +275,9 @@ export const FolderSelector: React.FC<IFolderSelectorProps> = ({
]}
onPress={() => handleSelectFolder(folder.Id)}
>
<View style={{ width: folder.depth * 16 }} />
<MaterialIcons
name="folder"
name={folder.children.length > 0 ? "folder" : "folder-open"}
size={22}
color={selectedFolderId === folder.Id ? colors.tint : colors.textMuted}
/>
@@ -202,7 +313,7 @@ export const FolderSelector: React.FC<IFolderSelectorProps> = ({
color={selectedFolderId ? colors.tint : colors.textMuted}
/>
<Text style={styles.buttonText} numberOfLines={1}>
{selectedFolder ? selectedFolder.Name : t('items.folders.noFolder')}
{getSelectedFolderPath()}
</Text>
<MaterialIcons
name="keyboard-arrow-down"

View File

@@ -335,8 +335,8 @@ export function ItemCard({ item, onItemDelete, showFolderPath = false }: ItemCar
<ItemIcon item={item} style={styles.logo} />
<View style={styles.itemInfo}>
<View style={styles.serviceNameRow}>
{showFolderPath && item.FolderPath && (
<Text style={styles.folderPath}>{item.FolderPath} &gt; </Text>
{showFolderPath && item.FolderPath && item.FolderPath.length > 0 && (
<Text style={styles.folderPath}>{item.FolderPath.join(' > ')} &gt; </Text>
)}
<Text style={styles.serviceName}>
{getItemName(item)}

View File

@@ -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,

View File

@@ -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)
}
}

View File

@@ -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
"""

View File

@@ -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)
}
}

View File

@@ -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 }
}
}

View File

@@ -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<string, ItemField[]>,
tagsByItem: Map<string, ItemTagRef[]>
tagsByItem: Map<string, ItemTagRef[]>,
folderPathsByFolderId?: Map<string, string[]>
): 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,

View File

@@ -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`;

View File

@@ -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<string[]> {
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;
});
}

View File

@@ -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<Map<string, string[]>> {
const folderPathMap = new Map<string, string[]>();
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<FieldRow>(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);
});
}
/**