mirror of
https://github.com/lanedirt/AliasVault.git
synced 2025-12-05 19:07:26 -06:00
Add recently deleted items scaffolding (#1404)
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 =====
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user