Add recently deleted items scaffolding (#1404)

This commit is contained in:
Leendert de Borst
2025-12-05 22:54:45 +01:00
parent 65977c1544
commit 864290e619
6 changed files with 679 additions and 59 deletions

View File

@@ -23,6 +23,7 @@ import ItemAddEdit from '@/entrypoints/popup/pages/credentials/ItemAddEdit';
import ItemDetails from '@/entrypoints/popup/pages/credentials/ItemDetails';
import ItemTypeSelector from '@/entrypoints/popup/pages/credentials/ItemTypeSelector';
import ItemsList from '@/entrypoints/popup/pages/items/ItemsList';
import RecentlyDeleted from '@/entrypoints/popup/pages/items/RecentlyDeleted';
import EmailDetails from '@/entrypoints/popup/pages/emails/EmailDetails';
import EmailsList from '@/entrypoints/popup/pages/emails/EmailsList';
import Index from '@/entrypoints/popup/pages/Index';
@@ -195,6 +196,7 @@ const App: React.FC = () => {
{ path: '/items/:id', element: <ItemDetails />, showBackButton: true, title: 'Item Details' },
{ path: '/items/:id/edit', element: <ItemAddEdit />, showBackButton: true, title: 'Edit Item' },
{ path: '/items/add', element: <ItemAddEdit />, showBackButton: true, title: 'Add Item' },
{ path: '/items/deleted', element: <RecentlyDeleted />, showBackButton: true, title: t('recentlyDeleted.title') },
{ path: '/passkeys/create', element: <PasskeyCreate />, layout: LayoutType.PASSKEY },
{ path: '/passkeys/authenticate', element: <PasskeyAuthenticate />, layout: LayoutType.PASSKEY },
{ path: '/emails', element: <EmailsList />, showBackButton: false },

View File

@@ -273,8 +273,8 @@ const ItemAddEdit: React.FC = () => {
await dbContext.sqliteClient!.deleteItemById(item.Id);
});
/* Navigate back to credentials list */
navigate('/credentials');
/* Navigate back to items list */
navigate('/items');
} catch (err) {
console.error('Error deleting item:', err);
} finally {
@@ -289,7 +289,7 @@ const ItemAddEdit: React.FC = () => {
if (isEditMode) {
navigate(`/items/${id}`);
} else {
navigate('/credentials');
navigate('/items');
}
}, [isEditMode, id, navigate]);

View File

@@ -92,6 +92,7 @@ const ItemsList: React.FC = () => {
const [currentFolderId, setCurrentFolderId] = useState<string | null>(null);
const [folderPath, setFolderPath] = useState<string>('');
const [showFolderModal, setShowFolderModal] = useState(false);
const [recentlyDeletedCount, setRecentlyDeletedCount] = useState(0);
const { setIsInitialLoading } = useLoading();
/**
@@ -231,6 +232,9 @@ const ItemsList: React.FC = () => {
setIsLoading(true);
const results = dbContext.sqliteClient?.getAllItems() ?? [];
setItems(results);
// Also get recently deleted count
const deletedCount = dbContext.sqliteClient?.getRecentlyDeletedCount() ?? 0;
setRecentlyDeletedCount(deletedCount);
setIsLoading(false);
setIsInitialLoading(false);
}
@@ -479,6 +483,16 @@ const ItemsList: React.FC = () => {
>
{t('items.filters.attachments')}
</button>
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
<button
onClick={() => {
setShowFilterMenu(false);
navigate('/items/deleted');
}}
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
{t('recentlyDeleted.title')}
</button>
</div>
</div>
</>
@@ -503,14 +517,42 @@ const ItemsList: React.FC = () => {
)}
{items.length === 0 ? (
<div className="text-gray-500 dark:text-gray-400 space-y-2 mb-10">
<p>
{t('items.welcomeTitle')}
</p>
<p>
{t('items.welcomeDescription')}
</p>
</div>
<>
<div className="text-gray-500 dark:text-gray-400 space-y-2 mb-10">
<p>
{t('items.welcomeTitle')}
</p>
<p>
{t('items.welcomeDescription')}
</p>
</div>
{/* Show Recently Deleted even when vault is empty */}
{recentlyDeletedCount > 0 && (
<button
onClick={() => navigate('/items/deleted')}
className="w-full p-3 flex items-center justify-between text-left bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
>
<div className="flex items-center gap-2">
<svg
className="w-5 h-5 text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
<span className="text-gray-700 dark:text-gray-300">{t('recentlyDeleted.title')}</span>
</div>
<span className="text-sm text-gray-500 dark:text-gray-400">
{recentlyDeletedCount}
</span>
</button>
)}
</>
) : filteredItems.length === 0 && folders.length === 0 ? (
<div className="text-gray-500 dark:text-gray-400 space-y-2 mb-10">
<p>
@@ -571,6 +613,35 @@ const ItemsList: React.FC = () => {
</ul>
</div>
)}
{/* Recently Deleted link (only show at root level when not searching) */}
{!currentFolderId && !searchTerm && (
<button
onClick={() => navigate('/items/deleted')}
className="w-full mt-4 p-3 flex items-center justify-between text-left bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
>
<div className="flex items-center gap-2">
<svg
className="w-5 h-5 text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
<span className="text-gray-700 dark:text-gray-300">{t('recentlyDeleted.title')}</span>
</div>
{recentlyDeletedCount > 0 && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{recentlyDeletedCount}
</span>
)}
</button>
)}
</>
)}

View File

@@ -0,0 +1,330 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate';
import type { Item } from '@/utils/dist/shared/models/vault';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
/**
* Calculate days remaining until permanent deletion.
* @param deletedAt - ISO timestamp when item was deleted
* @param retentionDays - Number of days to retain (default 30)
* @returns Number of days remaining, or 0 if already expired
*/
const getDaysRemaining = (deletedAt: string, retentionDays: number = 30): number => {
const deletedDate = new Date(deletedAt);
const expiryDate = new Date(deletedDate.getTime() + retentionDays * 24 * 60 * 60 * 1000);
const now = new Date();
const daysRemaining = Math.ceil((expiryDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000));
return Math.max(0, daysRemaining);
};
/**
* Recently Deleted page - shows items in trash that can be restored or permanently deleted.
*/
const RecentlyDeleted: React.FC = () => {
const { t } = useTranslation();
const dbContext = useDb();
const { executeVaultMutation } = useVaultMutate();
const { setHeaderButtons } = useHeaderButtons();
const [items, setItems] = useState<Item[]>([]);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const [showConfirmEmptyAll, setShowConfirmEmptyAll] = useState(false);
/**
* Loading state with minimum duration for more fluid UX.
*/
const [isLoading, setIsLoading] = useMinDurationLoading(true, 100);
/**
* Load recently deleted items.
*/
const loadItems = useCallback(() => {
if (dbContext?.sqliteClient) {
const results = dbContext.sqliteClient.getRecentlyDeletedItems();
setItems(results);
}
}, [dbContext?.sqliteClient]);
/**
* Restore an item from Recently Deleted.
*/
const handleRestore = useCallback(async (itemId: string) => {
if (!dbContext?.sqliteClient) {
return;
}
await executeVaultMutation(
async () => {
await dbContext.sqliteClient!.restoreItem(itemId);
},
{
/**
* On success callback.
*/
onSuccess: () => {
loadItems();
},
/**
* On error callback.
* @param error - The error that occurred
*/
onError: (error) => {
console.error('Error restoring item:', error);
}
}
);
}, [dbContext?.sqliteClient, executeVaultMutation, loadItems]);
/**
* Permanently delete an item.
*/
const handlePermanentDelete = useCallback(async (itemId: string) => {
if (!dbContext?.sqliteClient) {
return;
}
await executeVaultMutation(
async () => {
await dbContext.sqliteClient!.permanentlyDeleteItem(itemId);
},
{
/**
* On success callback.
*/
onSuccess: () => {
loadItems();
setShowConfirmDelete(false);
setSelectedItemId(null);
},
/**
* On error callback.
* @param error - The error that occurred
*/
onError: (error) => {
console.error('Error permanently deleting item:', error);
}
}
);
}, [dbContext?.sqliteClient, executeVaultMutation, loadItems]);
/**
* Empty all items from Recently Deleted (permanent delete all).
*/
const handleEmptyAll = useCallback(async () => {
if (!dbContext?.sqliteClient) {
return;
}
await executeVaultMutation(
async () => {
for (const item of items) {
await dbContext.sqliteClient!.permanentlyDeleteItem(item.Id);
}
},
{
/**
* On success callback.
*/
onSuccess: () => {
loadItems();
setShowConfirmEmptyAll(false);
},
/**
* On error callback.
* @param error - The error that occurred
*/
onError: (error) => {
console.error('Error emptying recently deleted:', error);
}
}
);
}, [dbContext?.sqliteClient, executeVaultMutation, items, loadItems]);
// Clear header buttons on mount
useEffect((): (() => void) => {
setHeaderButtons(null);
return () => setHeaderButtons(null);
}, [setHeaderButtons]);
// Load items on mount and when sqlite client changes
useEffect(() => {
/**
* Load items from database.
*/
const load = async (): Promise<void> => {
if (dbContext?.sqliteClient) {
setIsLoading(true);
loadItems();
setIsLoading(false);
}
};
load();
}, [dbContext?.sqliteClient, setIsLoading, loadItems]);
if (isLoading) {
return (
<div className="flex justify-center items-center p-8">
<LoadingSpinner />
</div>
);
}
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="flex items-baseline gap-1.5 text-gray-900 dark:text-white text-xl">
{t('recentlyDeleted.title')}
<span className="text-sm text-gray-500 dark:text-gray-400">({items.length})</span>
</h2>
{items.length > 0 && (
<button
onClick={() => setShowConfirmEmptyAll(true)}
className="text-sm text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
{t('recentlyDeleted.emptyAll')}
</button>
)}
</div>
{items.length === 0 ? (
<div className="text-gray-500 dark:text-gray-400 space-y-2 mb-10">
<p>{t('recentlyDeleted.noItems')}</p>
<p className="text-sm">{t('recentlyDeleted.noItemsDescription')}</p>
</div>
) : (
<>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{t('recentlyDeleted.description')}
</p>
<ul className="space-y-2">
{items.map(item => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deletedAt = (item as any).DeletedAt;
const daysRemaining = deletedAt ? getDaysRemaining(deletedAt) : 30;
return (
<li key={item.Id} className="relative">
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3">
<div className="flex items-start gap-3">
{/* Item card content (simplified) */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{item.Logo && (
<img
src={`data:image/png;base64,${btoa(String.fromCharCode(...item.Logo))}`}
alt=""
className="w-6 h-6 rounded"
/>
)}
<span className="font-medium text-gray-900 dark:text-white truncate">
{item.Name || t('recentlyDeleted.untitledItem')}
</span>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{daysRemaining > 0 ? (
t('recentlyDeleted.daysRemaining', { count: daysRemaining })
) : (
<span className="text-red-500">{t('recentlyDeleted.expiringSoon')}</span>
)}
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
<button
onClick={() => handleRestore(item.Id)}
className="px-3 py-1 text-sm bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded hover:bg-green-200 dark:hover:bg-green-900/50"
>
{t('recentlyDeleted.restore')}
</button>
<button
onClick={() => {
setSelectedItemId(item.Id);
setShowConfirmDelete(true);
}}
className="px-3 py-1 text-sm bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded hover:bg-red-200 dark:hover:bg-red-900/50"
>
{t('common.delete')}
</button>
</div>
</div>
</div>
</li>
);
})}
</ul>
</>
)}
{/* Confirm Delete Modal */}
{showConfirmDelete && selectedItemId && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-sm mx-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{t('recentlyDeleted.confirmDeleteTitle')}
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-4">
{t('recentlyDeleted.confirmDeleteMessage')}
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => {
setShowConfirmDelete(false);
setSelectedItemId(null);
}}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
{t('common.cancel')}
</button>
<button
onClick={() => handlePermanentDelete(selectedItemId)}
className="px-4 py-2 text-sm bg-red-600 text-white rounded hover:bg-red-700"
>
{t('recentlyDeleted.deletePermanently')}
</button>
</div>
</div>
</div>
)}
{/* Confirm Empty All Modal */}
{showConfirmEmptyAll && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-sm mx-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{t('recentlyDeleted.confirmEmptyAllTitle')}
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-4">
{t('recentlyDeleted.confirmEmptyAllMessage', { count: items.length })}
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowConfirmEmptyAll(false)}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
{t('common.cancel')}
</button>
<button
onClick={handleEmptyAll}
className="px-4 py-2 text-sm bg-red-600 text-white rounded hover:bg-red-700"
>
{t('recentlyDeleted.emptyAll')}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default RecentlyDeleted;

View File

@@ -474,6 +474,23 @@
"passkeyProviderOn": "Passkey Provider on "
}
},
"recentlyDeleted": {
"title": "Recently Deleted",
"noItems": "No deleted items",
"noItemsDescription": "Items you delete will appear here for 30 days before being permanently removed.",
"description": "These items will be permanently deleted after 30 days. You can restore them or delete them immediately.",
"restore": "Restore",
"deletePermanently": "Delete Permanently",
"emptyAll": "Empty All",
"daysRemaining": "{{count}} day remaining",
"daysRemaining_plural": "{{count}} days remaining",
"expiringSoon": "Expiring soon",
"untitledItem": "Untitled Item",
"confirmDeleteTitle": "Delete Permanently?",
"confirmDeleteMessage": "This item will be permanently deleted and cannot be recovered.",
"confirmEmptyAllTitle": "Empty Recently Deleted?",
"confirmEmptyAllMessage": "All {{count}} items will be permanently deleted and cannot be recovered."
},
"upgrade": {
"title": "Upgrade Vault",
"subtitle": "AliasVault has updated and your vault needs to be upgraded. This should only take a few seconds.",

View File

@@ -225,7 +225,7 @@ export class SqliteClient {
(SELECT pk.DisplayName FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0 LIMIT 1) as PasskeyDisplayName
FROM Items i
LEFT JOIN Logos l ON i.LogoId = l.Id
WHERE i.IsDeleted = 0
WHERE i.IsDeleted = 0 AND i.DeletedAt IS NULL
AND i.Id = ?`;
const results = this.executeQuery(query, [credentialId]);
@@ -303,7 +303,7 @@ export class SqliteClient {
END as HasAttachment
FROM Items i
LEFT JOIN Logos l ON i.LogoId = l.Id
WHERE i.IsDeleted = 0
WHERE i.IsDeleted = 0 AND i.DeletedAt IS NULL
ORDER BY i.CreatedAt DESC`;
const results = this.executeQuery(query);
@@ -381,7 +381,7 @@ export class SqliteClient {
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
WHERE i.IsDeleted = 0 AND i.DeletedAt IS NULL
ORDER BY i.CreatedAt DESC`;
const items = this.executeQuery(query);
@@ -2402,11 +2402,12 @@ export class SqliteClient {
}
/**
* Delete an item by ID (soft delete)
* @param itemId - The ID of the item to delete
* Move an item to "Recently Deleted" (trash) by setting DeletedAt timestamp.
* Item can be restored within retention period (default 30 days).
* @param itemId - The ID of the item to trash
* @returns The number of rows updated
*/
public async deleteItemById(itemId: string): Promise<number> {
public async trashItem(itemId: string): Promise<number> {
if (!this.db) {
throw new Error('Database not initialized');
}
@@ -2416,60 +2417,259 @@ export class SqliteClient {
const currentDateTime = dateFormatter.now();
// 1. Soft delete the item
const itemQuery = `
const query = `
UPDATE Items
SET IsDeleted = 1,
SET DeletedAt = ?,
UpdatedAt = ?
WHERE Id = ?`;
WHERE Id = ? AND IsDeleted = 0`;
const result = this.executeUpdate(itemQuery, [currentDateTime, itemId]);
// 2. Soft delete all associated FieldValues
const fieldsQuery = `
UPDATE FieldValues
SET IsDeleted = 1,
UpdatedAt = ?
WHERE ItemId = ?`;
this.executeUpdate(fieldsQuery, [currentDateTime, itemId]);
// 3. Soft delete associated Passkeys
const passkeysQuery = `
UPDATE Passkeys
SET IsDeleted = 1,
UpdatedAt = ?
WHERE ItemId = ?`;
this.executeUpdate(passkeysQuery, [currentDateTime, itemId]);
// 4. Soft delete associated TotpCodes
const totpQuery = `
UPDATE TotpCodes
SET IsDeleted = 1,
UpdatedAt = ?
WHERE ItemId = ?`;
this.executeUpdate(totpQuery, [currentDateTime, itemId]);
// 5. Soft delete associated Attachments
const attachmentsQuery = `
UPDATE Attachments
SET IsDeleted = 1,
UpdatedAt = ?
WHERE ItemId = ?`;
this.executeUpdate(attachmentsQuery, [currentDateTime, itemId]);
const result = this.executeUpdate(query, [currentDateTime, currentDateTime, itemId]);
await this.commitTransaction();
return result;
} catch (error) {
this.rollbackTransaction();
console.error('Error deleting item:', error);
console.error('Error trashing item:', error);
throw error;
}
}
/**
* Restore an item from "Recently Deleted" by clearing DeletedAt timestamp.
* @param itemId - The ID of the item to restore
* @returns The number of rows updated
*/
public async restoreItem(itemId: string): Promise<number> {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
this.beginTransaction();
const currentDateTime = dateFormatter.now();
const query = `
UPDATE Items
SET DeletedAt = NULL,
UpdatedAt = ?
WHERE Id = ? AND IsDeleted = 0 AND DeletedAt IS NOT NULL`;
const result = this.executeUpdate(query, [currentDateTime, itemId]);
await this.commitTransaction();
return result;
} catch (error) {
this.rollbackTransaction();
console.error('Error restoring item:', error);
throw error;
}
}
/**
* Permanently delete an item - converts to tombstone for sync.
* Hard deletes all child entities and marks item as IsDeleted=1.
* @param itemId - The ID of the item to permanently delete
* @returns The number of rows updated
*/
public async permanentlyDeleteItem(itemId: string): Promise<number> {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
this.beginTransaction();
const currentDateTime = dateFormatter.now();
// 1. Hard delete all FieldValues for this item
this.executeUpdate(`DELETE FROM FieldValues WHERE ItemId = ?`, [itemId]);
// 2. Hard delete all FieldHistories for this item
this.executeUpdate(`DELETE FROM FieldHistories WHERE ItemId = ?`, [itemId]);
// 3. Hard delete all Passkeys for this item
this.executeUpdate(`DELETE FROM Passkeys WHERE ItemId = ?`, [itemId]);
// 4. Hard delete all TotpCodes for this item
this.executeUpdate(`DELETE FROM TotpCodes WHERE ItemId = ?`, [itemId]);
// 5. Hard delete all Attachments for this item
this.executeUpdate(`DELETE FROM Attachments WHERE ItemId = ?`, [itemId]);
// 6. Hard delete all ItemTags for this item
this.executeUpdate(`DELETE FROM ItemTags WHERE ItemId = ?`, [itemId]);
// 7. Convert item to tombstone (gut it, keep shell for sync)
const itemQuery = `
UPDATE Items
SET IsDeleted = 1,
Name = NULL,
LogoId = NULL,
FolderId = NULL,
UpdatedAt = ?
WHERE Id = ?`;
const result = this.executeUpdate(itemQuery, [currentDateTime, itemId]);
await this.commitTransaction();
return result;
} catch (error) {
this.rollbackTransaction();
console.error('Error permanently deleting item:', error);
throw error;
}
}
/**
* Get all items in "Recently Deleted" (where DeletedAt is set but not permanently deleted).
* @returns Array of trashed Item objects
*/
public getRecentlyDeletedItems(): Item[] {
const query = `
SELECT DISTINCT
i.Id,
i.Name,
i.ItemType,
i.FolderId,
f.Name as FolderPath,
l.FileData as Logo,
i.DeletedAt,
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,
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
WHERE i.IsDeleted = 0 AND i.DeletedAt IS NOT NULL
ORDER BY i.DeletedAt DESC`;
const items = this.executeQuery(query);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const itemIds = items.map((i: any) => i.Id);
if (itemIds.length === 0) {
return [];
}
// Get all field values
const fieldsQuery = `
SELECT
fv.ItemId,
fv.FieldKey,
fv.FieldDefinitionId,
fd.Label as CustomLabel,
fd.FieldType as CustomFieldType,
fd.IsHidden as CustomIsHidden,
fv.Value,
fv.Weight as DisplayOrder
FROM FieldValues fv
LEFT JOIN FieldDefinitions fd ON fv.FieldDefinitionId = fd.Id
WHERE fv.ItemId IN (${itemIds.map(() => '?').join(',')})
AND fv.IsDeleted = 0
ORDER BY fv.ItemId, fv.Weight`;
const fieldRows = this.executeQuery<{
ItemId: string;
FieldKey: string | null;
FieldDefinitionId: string | null;
CustomLabel: string | null;
CustomFieldType: string | null;
CustomIsHidden: number | null;
Value: string;
DisplayOrder: number;
}>(fieldsQuery, itemIds);
// Process fields
const fields = fieldRows.map(row => {
if (row.FieldKey) {
const systemField = getSystemField(row.FieldKey);
return {
ItemId: row.ItemId,
FieldKey: row.FieldKey,
Label: systemField?.Label || row.FieldKey,
FieldType: systemField?.FieldType || 'Text',
IsHidden: systemField?.IsHidden ? 1 : 0,
Value: row.Value,
DisplayOrder: row.DisplayOrder
};
} else {
return {
ItemId: row.ItemId,
FieldKey: row.FieldDefinitionId || '',
Label: row.CustomLabel || '',
FieldType: row.CustomFieldType || 'Text',
IsHidden: row.CustomIsHidden || 0,
Value: row.Value,
DisplayOrder: row.DisplayOrder
};
}
});
// Group fields by item ID
const fieldsByItem = new Map<string, typeof fields>();
fields.forEach(field => {
const existing = fieldsByItem.get(field.ItemId) || [];
existing.push(field);
fieldsByItem.set(field.ItemId, existing);
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return items.map((row: any) => {
const itemFields = fieldsByItem.get(row.Id) || [];
return {
Id: row.Id,
Name: row.Name,
ItemType: row.ItemType,
FolderId: row.FolderId,
FolderPath: row.FolderPath,
Logo: row.Logo ? new Uint8Array(row.Logo) : undefined,
DeletedAt: row.DeletedAt,
HasPasskey: row.HasPasskey === 1,
HasAttachment: row.HasAttachment === 1,
HasTotp: row.HasTotp === 1,
Fields: itemFields.map(f => ({
FieldKey: f.FieldKey,
Label: f.Label,
FieldType: f.FieldType as FieldType,
Value: f.Value,
IsHidden: f.IsHidden === 1,
DisplayOrder: f.DisplayOrder
})),
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt
};
});
}
/**
* Get count of items in "Recently Deleted".
* @returns Number of trashed items
*/
public getRecentlyDeletedCount(): number {
const query = `
SELECT COUNT(*) as count
FROM Items
WHERE IsDeleted = 0 AND DeletedAt IS NOT NULL`;
const result = this.executeQuery<{ count: number }>(query);
return result[0]?.count || 0;
}
/**
* Delete an item by ID (soft delete) - DEPRECATED, use trashItem instead.
* Kept for backwards compatibility.
* @param itemId - The ID of the item to delete
* @returns The number of rows updated
* @deprecated Use trashItem() for new code
*/
public async deleteItemById(itemId: string): Promise<number> {
// Redirect to new trash functionality
return this.trashItem(itemId);
}
/**
* ===== FOLDER OPERATIONS =====
*/