mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-04-02 22:12:29 -04:00
Add mobile app subfolder support scaffolding (#1695)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
/**
|
||||
|
||||
241
apps/mobile-app/components/folders/FolderBreadcrumb.tsx
Normal file
241
apps/mobile-app/components/folders/FolderBreadcrumb.tsx
Normal 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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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} > </Text>
|
||||
{showFolderPath && item.FolderPath && item.FolderPath.length > 0 && (
|
||||
<Text style={styles.folderPath}>{item.FolderPath.join(' > ')} > </Text>
|
||||
)}
|
||||
<Text style={styles.serviceName}>
|
||||
{getItemName(item)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
161
apps/mobile-app/ios/VaultStoreKit/Utils/FolderUtils.swift
Normal file
161
apps/mobile-app/ios/VaultStoreKit/Utils/FolderUtils.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user