Add folderUtils.ts to mobile app and refactor browser extension (#1695)

This commit is contained in:
Leendert de Borst
2026-04-06 23:04:56 +02:00
parent 399b94d708
commit 21c396ea33
5 changed files with 224 additions and 156 deletions

View File

@@ -24,7 +24,7 @@ import type { Folder } from '@/utils/db/repositories/FolderRepository';
import type { CredentialSortOrder } from '@/utils/db/repositories/SettingsRepository';
import type { Item, ItemType } from '@/utils/dist/core/models/vault';
import { ItemTypes } from '@/utils/dist/core/models/vault';
import { canHaveSubfolders, getFolderPath } from '@/utils/folderUtils';
import { canHaveSubfolders, getDescendantFolderIds, getFolderPath, getRecursiveItemCount } from '@/utils/folderUtils';
import { LocalPreferencesService } from '@/utils/LocalPreferencesService';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
@@ -532,12 +532,12 @@ const ItemsList: React.FC = () => {
* @param folderId - The folder ID to count items for
* @returns Total count of items in this folder and all descendant folders
*/
const getRecursiveItemCount = (folderId: string): number => {
const getRecursiveItemCountLocal = (folderId: string): number => {
// Start with direct items in this folder
let count = directFolderCounts.get(folderId) || 0;
// Add counts from all child folders recursively
const childFolderIds = getAllChildFolderIds(folderId);
const childFolderIds = getDescendantFolderIds(folderId, allFolders);
for (const childId of childFolderIds) {
count += directFolderCounts.get(childId) || 0;
}
@@ -549,41 +549,12 @@ const ItemsList: React.FC = () => {
const result = relevantFolders.map((folder: Folder) => ({
id: folder.Id,
name: folder.Name,
itemCount: getRecursiveItemCount(folder.Id)
itemCount: getRecursiveItemCountLocal(folder.Id)
})).sort((a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name));
return result;
};
/**
* Get all child folder IDs recursively for a given folder.
* @param folderId - The parent folder ID to get children for
* @returns Array of all descendant folder IDs
*/
const getAllChildFolderIds = useCallback((folderId: string): string[] => {
if (!dbContext?.sqliteClient) {
return [];
}
const allFolders = dbContext.sqliteClient.folders.getAll();
const childIds: string[] = [];
/**
* Recursively find all child folders.
* @param parentId - The parent folder ID
*/
const findChildren = (parentId: string): void => {
const children = allFolders.filter(f => f.ParentFolderId === parentId);
for (const child of children) {
childIds.push(child.Id);
findChildren(child.Id); // Recursively find descendants
}
};
findChildren(folderId);
return childIds;
}, [dbContext]);
/**
* Filter items based on current view (folder, search, filter type)
*/
@@ -593,7 +564,8 @@ const ItemsList: React.FC = () => {
// When searching inside a folder, include items in subfolders too
if (searchTerm) {
// Get all child folder IDs recursively
const childFolderIds = getAllChildFolderIds(currentFolderId);
const allFolders = dbContext?.sqliteClient?.folders.getAll() || [];
const childFolderIds = getDescendantFolderIds(currentFolderId, allFolders);
const allFolderIds = [currentFolderId, ...childFolderIds];
// Item must be in current folder or any subfolder
@@ -689,19 +661,13 @@ const ItemsList: React.FC = () => {
* Used for the delete folder modal to show accurate count.
*/
const totalItemCountInFolderTree = useMemo(() => {
if (!currentFolderId) {
if (!currentFolderId || !dbContext?.sqliteClient) {
return filteredItems.length;
}
// Get all child folder IDs
const childFolderIds = getAllChildFolderIds(currentFolderId);
const allFolderIds = [currentFolderId, ...childFolderIds];
// Count items in current folder and all child folders
return items.filter(item =>
item.FolderId && allFolderIds.includes(item.FolderId)
).length;
}, [currentFolderId, items, getAllChildFolderIds, filteredItems.length]);
const allFolders = dbContext.sqliteClient.folders.getAll();
return getRecursiveItemCount(currentFolderId, items, allFolders);
}, [currentFolderId, items, dbContext?.sqliteClient, filteredItems.length]);
/**
* Check if the current folder can have subfolders (not at max depth).

View File

@@ -296,3 +296,23 @@ export function getDescendantFolderIds(folderId: string, folders: Folder[]): str
traverse(folderId);
return descendants;
}
/**
* Get total count of items in a folder and all its subfolders.
* @param folderId - The folder ID to count items for
* @param allItems - All items in the vault
* @param allFolders - All folders in the vault
* @returns Total item count including subfolders
*/
export function getRecursiveItemCount(
folderId: string,
allItems: Array<{ FolderId?: string | null }>,
allFolders: Folder[]
): number {
// Get all descendant folder IDs
const descendantIds = getDescendantFolderIds(folderId, allFolders);
const allFolderIds = [folderId, ...descendantIds];
// Count items in current folder and all descendants
return allItems.filter(item => item.FolderId && allFolderIds.includes(item.FolderId)).length;
}

View File

@@ -12,6 +12,7 @@ import type { CredentialSortOrder } from '@/utils/db/repositories/SettingsReposi
import type { Item, ItemType } from '@/utils/dist/core/models/vault';
import { getFieldValue, FieldKey, ItemTypes } from '@/utils/dist/core/models/vault';
import emitter from '@/utils/EventEmitter';
import { canHaveSubfolders, getRecursiveItemCount } from '@/utils/folderUtils';
import { HapticsUtility } from '@/utils/HapticsUtility';
import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError';
@@ -89,6 +90,7 @@ export default function FolderViewScreen(): React.ReactNode {
const [folder, setFolder] = useState<Folder | null>(null);
const [subfolders, setSubfolders] = useState<FolderWithCount[]>([]);
const [canCreateSubfolder, setCanCreateSubfolder] = useState(false);
const [allFolders, setAllFolders] = useState<Folder[]>([]);
// No minimum loading delay for folder view since data is already in memory
const [isLoadingItems, setIsLoadingItems] = useState(false);
const [refreshing, setRefreshing] = useMinDurationLoading(false, 200);
@@ -186,38 +188,16 @@ export default function FolderViewScreen(): React.ReactNode {
const sortedItems = useSortedItems(filteredItems, sortOrder);
/**
* Get folder depth in the hierarchy.
* Calculate total item count including all items in current folder and all child folders recursively.
* Used for the delete folder modal to show accurate count.
*/
function getFolderDepth(folderId: string | null, folders: Folder[]): number | null {
if (!folderId) {
return null;
const totalItemCountInFolderTree = useMemo(() => {
if (!folderId || allFolders.length === 0) {
return 0;
}
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;
}
return getRecursiveItemCount(folderId, itemsList, allFolders);
}, [folderId, allFolders, itemsList]);
/**
* Load items in this folder, subfolders, and folder details.
@@ -244,37 +224,17 @@ export default function FolderViewScreen(): React.ReactNode {
// 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),
itemCount: getRecursiveItemCount(f.Id, items, folders),
}));
setSubfolders(subfoldersWithCounts);
setAllFolders(folders);
// Calculate if we can create subfolders (check depth)
const depth = getFolderDepth(folderId, folders);
setCanCreateSubfolder(depth !== null && depth < 4);
setCanCreateSubfolder(canHaveSubfolders(folderId, folders));
setSortOrder(savedSortOrder);
setIsLoadingItems(false);
@@ -1034,7 +994,7 @@ export default function FolderViewScreen(): React.ReactNode {
onClose={() => setShowDeleteFolderModal(false)}
onDeleteFolderOnly={handleDeleteFolderOnly}
onDeleteFolderAndContents={handleDeleteFolderAndContents}
itemCount={itemsList.length}
itemCount={totalItemCountInFolderTree}
/>
</ThemedContainer>
);

View File

@@ -4,7 +4,7 @@ import React, { useState, useEffect, 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 { getFolderIdPath, getFolderPath } from '@/utils/folderUtils';
import { useColors } from '@/hooks/useColorScheme';
import { useDb } from '@/context/DbContext';
@@ -32,64 +32,6 @@ type FolderBreadcrumbProps = {
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.

View File

@@ -0,0 +1,180 @@
import type { Folder } from './db/repositories/FolderRepository';
/**
* 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.
*/
export const MAX_FOLDER_DEPTH = 4;
/**
* Get folder depth in the hierarchy.
* @param folderId - The folder ID to check
* @param folders - Flat array of all folders
* @returns Depth (0 = root, 1 = one level deep, etc.) or null if folder not found
*/
export function getFolderDepth(folderId: string, folders: Folder[]): number | null {
const folder = folders.find(f => f.Id === folderId);
if (!folder) {
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 > 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
* @returns Array of folder names from root to current folder, or empty array if not found
*/
export 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 < MAX_FOLDER_DEPTH + 1) {
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
*/
export 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 < MAX_FOLDER_DEPTH + 1) {
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;
}
/**
* Format folder path for display with separator.
* @param pathSegments - Array of folder names
* @param separator - Separator string (default: " > ")
* @returns Formatted folder path string
*/
export function formatFolderPath(
pathSegments: string[],
separator: string = ' > '
): string {
return pathSegments.join(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
* @returns True if folder can have children, false otherwise
*/
export function canHaveSubfolders(folderId: string, folders: Folder[]): boolean {
const 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
* @returns Array of descendant folder IDs
*/
export function getDescendantFolderIds(folderId: string, folders: Folder[]): string[] {
const descendants: string[] = [];
/**
* Traverse a folder tree and get all descendant folder IDs.
*/
const traverse = (parentId: string): void => {
folders
.filter(f => f.ParentFolderId === parentId)
.forEach(child => {
descendants.push(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
* @returns Array of direct child folder IDs
*/
export function getDirectChildFolderIds(parentFolderId: string | null, folders: Folder[]): string[] {
return folders
.filter(f => f.ParentFolderId === parentFolderId)
.map(f => f.Id);
}
/**
* Get total count of items in a folder and all its subfolders.
* @param folderId - The folder ID to count items for
* @param allItems - All items in the vault
* @param allFolders - All folders in the vault
* @returns Total item count including subfolders
*/
export function getRecursiveItemCount(
folderId: string,
allItems: Array<{ FolderId?: string | null }>,
allFolders: Folder[]
): number {
// Get all descendant folder IDs
const descendantIds = getDescendantFolderIds(folderId, allFolders);
const allFolderIds = [folderId, ...descendantIds];
// Count items in current folder and all descendants
return allItems.filter(item => item.FolderId && allFolderIds.includes(item.FolderId)).length;
}