mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-24 16:32:20 -04:00
Refactor browser extension sqliteclient to use custom ORM similar to .NET EF repository pattern (#1404)
This commit is contained in:
@@ -24,6 +24,7 @@ import type {
|
||||
WebAuthnPublicKeyGetPayload
|
||||
} from '@/utils/passkey/types';
|
||||
import { SqliteClient } from '@/utils/SqliteClient';
|
||||
import type { PasskeyWithItem } from '@/utils/db/mappers/PasskeyMapper';
|
||||
|
||||
import { browser, storage } from '#imports';
|
||||
|
||||
@@ -232,7 +233,7 @@ async function checkForMatchingPasskeys(publicKey: any, origin: string): Promise
|
||||
const rpId = publicKey.rpId || new URL(origin).hostname;
|
||||
|
||||
// Get passkeys for this rpId
|
||||
const passkeys = sqliteClient.getPasskeysByRpId(rpId);
|
||||
const passkeys = sqliteClient.passkeys.getByRpId(rpId);
|
||||
|
||||
// If allowCredentials is specified, filter by those specific credentials
|
||||
if (publicKey.allowCredentials && publicKey.allowCredentials.length > 0) {
|
||||
@@ -249,7 +250,7 @@ async function checkForMatchingPasskeys(publicKey: any, origin: string): Promise
|
||||
);
|
||||
|
||||
// Check if we have any of the allowed credentials
|
||||
const matchingPasskeys = passkeys.filter(pk => allowedGuids.has(pk.Id));
|
||||
const matchingPasskeys = passkeys.filter((pk: PasskeyWithItem) => allowedGuids.has(pk.Id));
|
||||
return matchingPasskeys.length > 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { storage } from 'wxt/utils/storage';
|
||||
|
||||
import type { EncryptionKeyDerivationParams } from '@/utils/dist/core/models/metadata';
|
||||
import type { Item } from '@/utils/dist/core/models/vault';
|
||||
import type { Vault, VaultResponse, VaultPostResponse } from '@/utils/dist/core/models/webapi';
|
||||
import { EncryptionUtility } from '@/utils/EncryptionUtility';
|
||||
import { SqliteClient } from '@/utils/SqliteClient';
|
||||
@@ -299,7 +300,7 @@ export async function handleCreateItem(
|
||||
const sqliteClient = await createVaultSqliteClient();
|
||||
|
||||
// Add the new item to the vault/database.
|
||||
await sqliteClient.createItem(message.item, message.attachments || [], message.totpCodes || []);
|
||||
await sqliteClient.items.create(message.item, message.attachments || [], message.totpCodes || []);
|
||||
|
||||
// Upload the new vault to the server.
|
||||
await uploadNewVaultToServer(sqliteClient);
|
||||
@@ -328,7 +329,7 @@ export async function handleGetFilteredItems(
|
||||
|
||||
try {
|
||||
const sqliteClient = await createVaultSqliteClient();
|
||||
const allItems = sqliteClient.getAllItems();
|
||||
const allItems = sqliteClient.items.getAll();
|
||||
|
||||
const { filterItems, AutofillMatchingMode } = await import('@/utils/credentialMatcher/CredentialMatcher');
|
||||
|
||||
@@ -370,7 +371,7 @@ export async function handleGetSearchItems(
|
||||
|
||||
try {
|
||||
const sqliteClient = await createVaultSqliteClient();
|
||||
const allItems = sqliteClient.getAllItems();
|
||||
const allItems = sqliteClient.items.getAll();
|
||||
|
||||
// If search term is empty, return empty array
|
||||
if (!message.searchTerm || message.searchTerm.trim() === '') {
|
||||
@@ -381,7 +382,7 @@ export async function handleGetSearchItems(
|
||||
const { FieldKey } = await import('@/utils/dist/core/models/vault');
|
||||
|
||||
// Filter items by search term across multiple fields
|
||||
const searchResults = allItems.filter(item => {
|
||||
const searchResults = allItems.filter((item: Item) => {
|
||||
// Search in item name
|
||||
if (item.Name?.toLowerCase().includes(searchTerm)) {
|
||||
return true;
|
||||
@@ -396,14 +397,14 @@ export async function handleGetSearchItems(
|
||||
FieldKey.AliasLastName
|
||||
];
|
||||
|
||||
return item.Fields?.some(field => {
|
||||
if (searchableFieldKeys.includes(field.FieldKey as any)) {
|
||||
return item.Fields?.some((field: { FieldKey: string; Value: string | string[] }) => {
|
||||
if ((searchableFieldKeys as string[]).includes(field.FieldKey)) {
|
||||
const value = Array.isArray(field.Value) ? field.Value.join(' ') : field.Value;
|
||||
return value?.toLowerCase().includes(searchTerm);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}).sort((a, b) => {
|
||||
}).sort((a: Item, b: Item) => {
|
||||
// Sort by name
|
||||
return (a.Name ?? '').localeCompare(b.Name ?? '');
|
||||
});
|
||||
@@ -421,7 +422,7 @@ export async function handleGetSearchItems(
|
||||
export async function getEmailAddressesForVault(
|
||||
sqliteClient: SqliteClient
|
||||
): Promise<string[]> {
|
||||
const emailAddresses = sqliteClient.getAllEmailAddresses();
|
||||
const emailAddresses = sqliteClient.items.getAllEmailAddresses();
|
||||
|
||||
// Get metadata from local: storage
|
||||
const privateEmailDomains = await getItemWithFallback<string[]>('local:privateEmailDomains') ?? [];
|
||||
@@ -439,7 +440,7 @@ export function handleGetDefaultEmailDomain(): Promise<stringResponse> {
|
||||
return (async (): Promise<stringResponse> => {
|
||||
try {
|
||||
const sqliteClient = await createVaultSqliteClient();
|
||||
const defaultEmailDomain = await sqliteClient.getDefaultEmailDomain();
|
||||
const defaultEmailDomain = sqliteClient.settings.getDefaultEmailDomain();
|
||||
|
||||
return { success: true, value: defaultEmailDomain ?? undefined };
|
||||
} catch (error) {
|
||||
@@ -457,8 +458,8 @@ export async function handleGetDefaultIdentitySettings(
|
||||
) : Promise<IdentitySettingsResponse> {
|
||||
try {
|
||||
const sqliteClient = await createVaultSqliteClient();
|
||||
const language = await sqliteClient.getEffectiveIdentityLanguage();
|
||||
const gender = sqliteClient.getDefaultIdentityGender();
|
||||
const language = sqliteClient.settings.getEffectiveIdentityLanguage();
|
||||
const gender = sqliteClient.settings.getDefaultIdentityGender();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -480,7 +481,7 @@ export async function handleGetPasswordSettings(
|
||||
) : Promise<messagePasswordSettingsResponse> {
|
||||
try {
|
||||
const sqliteClient = await createVaultSqliteClient();
|
||||
const passwordSettings = sqliteClient.getPasswordSettings();
|
||||
const passwordSettings = sqliteClient.settings.getPasswordSettings();
|
||||
|
||||
return { success: true, settings: passwordSettings };
|
||||
} catch (error) {
|
||||
@@ -623,7 +624,7 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<Vaul
|
||||
const newVault: Vault = {
|
||||
blob: encryptedVault,
|
||||
createdAt: new Date().toISOString(),
|
||||
credentialsCount: sqliteClient.getAllItems().length,
|
||||
credentialsCount: sqliteClient.items.getAll().length,
|
||||
currentRevisionNumber: serverRevision,
|
||||
emailAddressList: emailAddresses,
|
||||
updatedAt: new Date().toISOString(),
|
||||
|
||||
@@ -57,7 +57,7 @@ const AttachmentBlock: React.FC<AttachmentBlockProps> = ({ itemId }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const attachmentList = dbContext.sqliteClient.getAttachmentsForItem(itemId);
|
||||
const attachmentList = dbContext.sqliteClient.settings.getAttachmentsForItem(itemId);
|
||||
setAttachments(attachmentList);
|
||||
} catch (error) {
|
||||
console.error('Error loading attachments:', error);
|
||||
|
||||
@@ -49,7 +49,7 @@ const FieldBlock: React.FC<FieldBlockProps> = ({ field, itemId }) => {
|
||||
useEffect(() => {
|
||||
if (hasHistoryEnabled && itemId && dbContext?.sqliteClient) {
|
||||
try {
|
||||
const history = dbContext.sqliteClient.getFieldHistory(itemId, field.FieldKey);
|
||||
const history = dbContext.sqliteClient.items.getFieldHistory(itemId, field.FieldKey);
|
||||
setHistoryCount(history.length);
|
||||
} catch (error) {
|
||||
console.error('[FieldBlock] Error checking history:', error);
|
||||
|
||||
@@ -48,7 +48,7 @@ const FieldHistoryModal: React.FC<FieldHistoryModalProps> = ({
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const historyRecords = dbContext.sqliteClient.getFieldHistory(itemId, fieldKey);
|
||||
const historyRecords = dbContext.sqliteClient.items.getFieldHistory(itemId, fieldKey);
|
||||
setHistory(historyRecords);
|
||||
} catch (error) {
|
||||
console.error('Error loading field history:', error);
|
||||
|
||||
@@ -43,7 +43,7 @@ const PasskeyBlock: React.FC<PasskeyBlockProps> = ({ itemId }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const itemPasskeys = dbContext.sqliteClient.getPasskeysByItemId(itemId);
|
||||
const itemPasskeys = dbContext.sqliteClient.passkeys.getByItemId(itemId);
|
||||
setPasskeys(itemPasskeys);
|
||||
} catch (err) {
|
||||
console.error('Error loading passkeys:', err);
|
||||
|
||||
@@ -35,7 +35,7 @@ const PasskeyEditor: React.FC<PasskeyEditorProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const itemPasskeys = dbContext.sqliteClient.getPasskeysByItemId(itemId);
|
||||
const itemPasskeys = dbContext.sqliteClient.passkeys.getByItemId(itemId);
|
||||
setPasskeys(itemPasskeys);
|
||||
} catch (err) {
|
||||
console.error('Error loading passkeys:', err);
|
||||
|
||||
@@ -92,7 +92,7 @@ const TotpBlock: React.FC<TotpBlockProps> = ({ itemId }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const codes = dbContext.sqliteClient.getTotpCodesForItem(itemId);
|
||||
const codes = dbContext.sqliteClient.settings.getTotpCodesForItem(itemId);
|
||||
setTotpCodes(codes);
|
||||
} catch (error) {
|
||||
console.error('Error loading TOTP codes:', error);
|
||||
|
||||
@@ -140,7 +140,7 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
// Loop through all emails and decrypt them locally
|
||||
const decryptedEmails: MailboxEmail[] = await EncryptionUtility.decryptEmailList(
|
||||
allMails,
|
||||
dbContext.sqliteClient!.getAllEncryptionKeys()
|
||||
dbContext.sqliteClient!.settings.getAllEncryptionKeys()
|
||||
);
|
||||
|
||||
if (loading && decryptedEmails.length > 0) {
|
||||
|
||||
@@ -60,7 +60,7 @@ const PasswordField: React.FC<IPasswordFieldProps> = ({
|
||||
const loadSettings = async (): Promise<void> => {
|
||||
try {
|
||||
if (dbContext.sqliteClient) {
|
||||
const settings = dbContext.sqliteClient.getPasswordSettings();
|
||||
const settings = dbContext.sqliteClient.settings.getPasswordSettings();
|
||||
setCurrentSettings(settings);
|
||||
setIsLoaded(true);
|
||||
}
|
||||
|
||||
@@ -53,13 +53,13 @@ const useAliasGenerator = (): {
|
||||
}
|
||||
|
||||
// Get effective identity language (smart default based on UI language if no explicit override)
|
||||
const identityLanguage = await dbContext.sqliteClient.getEffectiveIdentityLanguage();
|
||||
const identityLanguage = dbContext.sqliteClient.settings.getEffectiveIdentityLanguage();
|
||||
|
||||
// Initialize identity generator based on language
|
||||
const identityGenerator = CreateIdentityGenerator(identityLanguage);
|
||||
|
||||
// Initialize password generator with settings from vault
|
||||
const passwordSettings = dbContext.sqliteClient.getPasswordSettings();
|
||||
const passwordSettings = dbContext.sqliteClient.settings.getPasswordSettings();
|
||||
const passwordGenerator = CreatePasswordGenerator(passwordSettings);
|
||||
|
||||
return { identityGenerator, passwordGenerator };
|
||||
@@ -78,17 +78,17 @@ const useAliasGenerator = (): {
|
||||
const { identityGenerator, passwordGenerator } = await initializeGenerators();
|
||||
|
||||
// Get gender preference from database
|
||||
const genderPreference = dbContext.sqliteClient.getDefaultIdentityGender();
|
||||
const genderPreference = dbContext.sqliteClient.settings.getDefaultIdentityGender();
|
||||
|
||||
// Get age range preference and convert to birthdate options
|
||||
const ageRange = dbContext.sqliteClient.getDefaultIdentityAgeRange();
|
||||
const ageRange = dbContext.sqliteClient.settings.getDefaultIdentityAgeRange();
|
||||
const birthdateOptions = convertAgeRangeToBirthdateOptions(ageRange);
|
||||
|
||||
// Generate identity with gender preference and birthdate options
|
||||
const identity = identityGenerator.generateRandomIdentity(genderPreference, birthdateOptions);
|
||||
const password = passwordGenerator.generateRandomPassword();
|
||||
|
||||
const defaultEmailDomain = await dbContext.sqliteClient.getDefaultEmailDomain();
|
||||
const defaultEmailDomain = dbContext.sqliteClient.settings.getDefaultEmailDomain();
|
||||
const email = defaultEmailDomain ? `${identity.emailPrefix}@${defaultEmailDomain}` : identity.emailPrefix;
|
||||
|
||||
const generatedData: GeneratedAliasData = {
|
||||
|
||||
@@ -429,7 +429,7 @@ const ItemAddEdit: React.FC = () => {
|
||||
|
||||
// Load folders
|
||||
if (dbContext?.sqliteClient) {
|
||||
const allFolders = dbContext.sqliteClient.getAllFolders();
|
||||
const allFolders = dbContext.sqliteClient.folders.getAll();
|
||||
setFolders(allFolders);
|
||||
}
|
||||
|
||||
@@ -453,12 +453,12 @@ const ItemAddEdit: React.FC = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = dbContext.sqliteClient.getItemById(id);
|
||||
const result = dbContext.sqliteClient.items.getById(id);
|
||||
if (result) {
|
||||
setItem(result);
|
||||
|
||||
// Load folders
|
||||
const allFolders = dbContext.sqliteClient.getAllFolders();
|
||||
const allFolders = dbContext.sqliteClient.folders.getAll();
|
||||
setFolders(allFolders);
|
||||
|
||||
// Initialize field values from existing fields
|
||||
@@ -466,7 +466,7 @@ const ItemAddEdit: React.FC = () => {
|
||||
const existingCustomFields: CustomFieldDefinition[] = [];
|
||||
const fieldsWithValues = new Set<string>();
|
||||
|
||||
result.Fields.forEach(field => {
|
||||
result.Fields.forEach((field) => {
|
||||
initialValues[field.FieldKey] = field.Value;
|
||||
// Track fields that have values so they stay visible even if cleared
|
||||
fieldsWithValues.add(field.FieldKey);
|
||||
@@ -488,17 +488,17 @@ const ItemAddEdit: React.FC = () => {
|
||||
setInitiallyVisibleFields(fieldsWithValues);
|
||||
|
||||
// Load TOTP codes for this item
|
||||
const itemTotpCodes = dbContext.sqliteClient.getTotpCodesForItem(id);
|
||||
const itemTotpCodes = dbContext.sqliteClient.settings.getTotpCodesForItem(id);
|
||||
setTotpCodes(itemTotpCodes);
|
||||
setOriginalTotpCodeIds(itemTotpCodes.map(tc => tc.Id));
|
||||
setOriginalTotpCodeIds(itemTotpCodes.map((tc) => tc.Id));
|
||||
if (itemTotpCodes.length > 0) {
|
||||
setShow2FA(true);
|
||||
}
|
||||
|
||||
// Load attachments for this item
|
||||
const itemAttachments = dbContext.sqliteClient.getAttachmentsForItem(id);
|
||||
const itemAttachments = dbContext.sqliteClient.settings.getAttachmentsForItem(id);
|
||||
setAttachments(itemAttachments);
|
||||
setOriginalAttachmentIds(itemAttachments.map(a => a.Id));
|
||||
setOriginalAttachmentIds(itemAttachments.map((a) => a.Id));
|
||||
if (itemAttachments.length > 0) {
|
||||
setShowAttachments(true);
|
||||
}
|
||||
@@ -720,7 +720,7 @@ const ItemAddEdit: React.FC = () => {
|
||||
setLocalLoading(false);
|
||||
|
||||
if (isEditMode) {
|
||||
await dbContext.sqliteClient!.updateItem(
|
||||
await dbContext.sqliteClient!.items.update(
|
||||
updatedItem,
|
||||
originalAttachmentIds,
|
||||
attachments,
|
||||
@@ -731,11 +731,11 @@ const ItemAddEdit: React.FC = () => {
|
||||
// Delete passkeys marked for deletion
|
||||
if (passkeyIdsMarkedForDeletion.length > 0) {
|
||||
for (const passkeyId of passkeyIdsMarkedForDeletion) {
|
||||
await dbContext.sqliteClient!.deletePasskeyById(passkeyId);
|
||||
await dbContext.sqliteClient!.passkeys.deleteById(passkeyId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await dbContext.sqliteClient!.createItem(updatedItem, attachments, totpCodes);
|
||||
await dbContext.sqliteClient!.items.create(updatedItem, attachments, totpCodes);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -762,7 +762,7 @@ const ItemAddEdit: React.FC = () => {
|
||||
|
||||
try {
|
||||
await executeVaultMutationAsync(async () => {
|
||||
await dbContext.sqliteClient!.deleteItemById(item.Id);
|
||||
await dbContext.sqliteClient!.items.trash(item.Id);
|
||||
});
|
||||
|
||||
navigate('/items');
|
||||
|
||||
@@ -59,7 +59,7 @@ const ItemDetails: React.FC = (): React.ReactElement => {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = dbContext.sqliteClient.getItemById(id);
|
||||
const result = dbContext.sqliteClient.items.getById(id);
|
||||
if (result) {
|
||||
setItem(result);
|
||||
setIsInitialLoading(false);
|
||||
|
||||
@@ -60,7 +60,7 @@ const EmailDetails: React.FC = (): React.ReactElement => {
|
||||
const response = await webApi.get<Email>(`Email/${id}`);
|
||||
|
||||
// Decrypt email locally using public/private key pairs
|
||||
const encryptionKeys = dbContext.sqliteClient.getAllEncryptionKeys();
|
||||
const encryptionKeys = dbContext.sqliteClient.settings.getAllEncryptionKeys();
|
||||
const decryptedEmail = await EncryptionUtility.decryptEmail(response, encryptionKeys);
|
||||
setEmail(decryptedEmail);
|
||||
} catch (err) {
|
||||
@@ -111,7 +111,7 @@ const EmailDetails: React.FC = (): React.ReactElement => {
|
||||
}
|
||||
|
||||
// Get encryption keys for decryption
|
||||
const encryptionKeys = dbContext.sqliteClient.getAllEncryptionKeys();
|
||||
const encryptionKeys = dbContext.sqliteClient.settings.getAllEncryptionKeys();
|
||||
|
||||
// Decrypt the attachment using raw bytes
|
||||
const decryptedBytes = await EncryptionUtility.decryptAttachment(encryptedBytes, email, encryptionKeys);
|
||||
|
||||
@@ -47,7 +47,7 @@ const EmailsList: React.FC = () => {
|
||||
}
|
||||
|
||||
// Get unique email addresses from all credentials.
|
||||
const emailAddresses = dbContext.sqliteClient.getAllEmailAddresses();
|
||||
const emailAddresses = dbContext.sqliteClient.items.getAllEmailAddresses();
|
||||
|
||||
try {
|
||||
// For now we only show the latest 50 emails. No pagination.
|
||||
@@ -58,7 +58,7 @@ const EmailsList: React.FC = () => {
|
||||
});
|
||||
|
||||
// Decrypt emails locally using private key associated with the email address.
|
||||
const encryptionKeys = dbContext.sqliteClient.getAllEncryptionKeys();
|
||||
const encryptionKeys = dbContext.sqliteClient.settings.getAllEncryptionKeys();
|
||||
|
||||
// Decrypt emails locally using public/private key pairs.
|
||||
const decryptedEmails = await EncryptionUtility.decryptEmailList(data.mails, encryptionKeys);
|
||||
|
||||
@@ -105,8 +105,8 @@ const ItemsList: React.FC = () => {
|
||||
if (!currentFolderId || !dbContext?.sqliteClient) {
|
||||
return null;
|
||||
}
|
||||
const folders = dbContext.sqliteClient.getAllFolders();
|
||||
const folder = folders.find(f => f.Id === currentFolderId);
|
||||
const folders = dbContext.sqliteClient.folders.getAll();
|
||||
const folder = folders.find((f: { Id: string; Name: string }) => f.Id === currentFolderId);
|
||||
return folder?.Name ?? null;
|
||||
}, [currentFolderId, dbContext?.sqliteClient]);
|
||||
|
||||
@@ -154,11 +154,11 @@ const ItemsList: React.FC = () => {
|
||||
}
|
||||
|
||||
await executeVaultMutationAsync(async () => {
|
||||
await dbContext.sqliteClient!.createFolder(folderName, currentFolderId);
|
||||
await dbContext.sqliteClient!.folders.create(folderName, currentFolderId);
|
||||
});
|
||||
|
||||
// Refresh items to show the new folder
|
||||
const results = dbContext.sqliteClient!.getAllItems();
|
||||
const results = dbContext.sqliteClient!.items.getAll();
|
||||
setItems(results);
|
||||
}, [dbContext, currentFolderId, executeVaultMutationAsync]);
|
||||
|
||||
@@ -171,13 +171,13 @@ const ItemsList: React.FC = () => {
|
||||
}
|
||||
|
||||
await executeVaultMutationAsync(async () => {
|
||||
await dbContext.sqliteClient!.deleteFolder(currentFolderId);
|
||||
await dbContext.sqliteClient!.folders.delete(currentFolderId);
|
||||
});
|
||||
|
||||
// Refresh items list to reflect changes
|
||||
const results = dbContext.sqliteClient!.getAllItems();
|
||||
const results = dbContext.sqliteClient!.items.getAll();
|
||||
setItems(results);
|
||||
const deletedCount = dbContext.sqliteClient!.getRecentlyDeletedCount();
|
||||
const deletedCount = dbContext.sqliteClient!.items.getRecentlyDeletedCount();
|
||||
setRecentlyDeletedCount(deletedCount);
|
||||
|
||||
// Navigate back to root
|
||||
@@ -193,13 +193,13 @@ const ItemsList: React.FC = () => {
|
||||
}
|
||||
|
||||
await executeVaultMutationAsync(async () => {
|
||||
await dbContext.sqliteClient!.deleteFolderWithContents(currentFolderId);
|
||||
await dbContext.sqliteClient!.folders.deleteWithContents(currentFolderId);
|
||||
});
|
||||
|
||||
// Refresh items list to reflect changes
|
||||
const results = dbContext.sqliteClient!.getAllItems();
|
||||
const results = dbContext.sqliteClient!.items.getAll();
|
||||
setItems(results);
|
||||
const deletedCount = dbContext.sqliteClient!.getRecentlyDeletedCount();
|
||||
const deletedCount = dbContext.sqliteClient!.items.getRecentlyDeletedCount();
|
||||
setRecentlyDeletedCount(deletedCount);
|
||||
|
||||
// Navigate back to root
|
||||
@@ -284,10 +284,10 @@ const ItemsList: React.FC = () => {
|
||||
const refreshItems = async () : Promise<void> => {
|
||||
if (dbContext?.sqliteClient) {
|
||||
setIsLoading(true);
|
||||
const results = dbContext.sqliteClient?.getAllItems() ?? [];
|
||||
const results = dbContext.sqliteClient?.items.getAll() ?? [];
|
||||
setItems(results);
|
||||
// Also get recently deleted count
|
||||
const deletedCount = dbContext.sqliteClient?.getRecentlyDeletedCount() ?? 0;
|
||||
const deletedCount = dbContext.sqliteClient?.items.getRecentlyDeletedCount() ?? 0;
|
||||
setRecentlyDeletedCount(deletedCount);
|
||||
setIsLoading(false);
|
||||
setIsInitialLoading(false);
|
||||
@@ -336,22 +336,22 @@ const ItemsList: React.FC = () => {
|
||||
}
|
||||
|
||||
// Get all folders directly from the database
|
||||
const allFolders = dbContext.sqliteClient.getAllFolders();
|
||||
const allFolders = dbContext.sqliteClient.folders.getAll();
|
||||
|
||||
// Count items per folder
|
||||
const folderCounts = new Map<string, number>();
|
||||
items.forEach(item => {
|
||||
items.forEach((item: Item) => {
|
||||
if (item.FolderId) {
|
||||
folderCounts.set(item.FolderId, (folderCounts.get(item.FolderId) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Build result with counts
|
||||
const result = allFolders.map(folder => ({
|
||||
const result = allFolders.map((folder: { Id: string; Name: string }) => ({
|
||||
id: folder.Id,
|
||||
name: folder.Name,
|
||||
itemCount: folderCounts.get(folder.Id) || 0
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
})).sort((a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name));
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -48,7 +48,7 @@ const RecentlyDeleted: React.FC = () => {
|
||||
*/
|
||||
const loadItems = useCallback(() => {
|
||||
if (dbContext?.sqliteClient) {
|
||||
const results = dbContext.sqliteClient.getRecentlyDeletedItems();
|
||||
const results = dbContext.sqliteClient.items.getRecentlyDeleted();
|
||||
setItems(results);
|
||||
}
|
||||
}, [dbContext?.sqliteClient]);
|
||||
@@ -62,7 +62,7 @@ const RecentlyDeleted: React.FC = () => {
|
||||
}
|
||||
|
||||
await executeVaultMutationAsync(async () => {
|
||||
await dbContext.sqliteClient!.restoreItem(itemId);
|
||||
await dbContext.sqliteClient!.items.restore(itemId);
|
||||
});
|
||||
|
||||
loadItems();
|
||||
@@ -77,7 +77,7 @@ const RecentlyDeleted: React.FC = () => {
|
||||
}
|
||||
|
||||
await executeVaultMutationAsync(async () => {
|
||||
await dbContext.sqliteClient!.permanentlyDeleteItem(selectedItemId);
|
||||
await dbContext.sqliteClient!.items.permanentlyDelete(selectedItemId);
|
||||
});
|
||||
|
||||
loadItems();
|
||||
@@ -95,7 +95,7 @@ const RecentlyDeleted: React.FC = () => {
|
||||
|
||||
await executeVaultMutationAsync(async () => {
|
||||
for (const item of items) {
|
||||
await dbContext.sqliteClient!.permanentlyDeleteItem(item.Id);
|
||||
await dbContext.sqliteClient!.items.permanentlyDelete(item.Id);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -63,27 +63,27 @@ const PasskeyAuthenticate: React.FC = () => {
|
||||
|
||||
// Get passkeys for this rpId from the vault
|
||||
const rpId = data.publicKey.rpId || new URL(data.origin).hostname;
|
||||
const passkeys = dbContext.sqliteClient!.getPasskeysByRpId(rpId);
|
||||
const passkeys = dbContext.sqliteClient!.passkeys.getByRpId(rpId);
|
||||
|
||||
// Filter by allowCredentials if specified
|
||||
let filteredPasskeys = passkeys;
|
||||
if (data.publicKey.allowCredentials && data.publicKey.allowCredentials.length > 0) {
|
||||
// Convert the RP's base64url credential IDs to GUIDs for comparison
|
||||
const allowedGuids = new Set(
|
||||
data.publicKey.allowCredentials.map(c => {
|
||||
data.publicKey.allowCredentials.map((c: { id: string }) => {
|
||||
try {
|
||||
return PasskeyHelper.base64urlToGuid(c.id);
|
||||
} catch (e) {
|
||||
console.warn('Failed to convert credential ID to GUID:', c.id, e);
|
||||
return null;
|
||||
}
|
||||
}).filter((id): id is string => id !== null)
|
||||
}).filter((id: string | null): id is string => id !== null)
|
||||
);
|
||||
filteredPasskeys = passkeys.filter(pk => allowedGuids.has(pk.Id));
|
||||
filteredPasskeys = passkeys.filter((pk: { Id: string }) => allowedGuids.has(pk.Id));
|
||||
}
|
||||
|
||||
// Map to display format
|
||||
setAvailablePasskeys(filteredPasskeys.map(pk => ({
|
||||
setAvailablePasskeys(filteredPasskeys.map((pk: { Id: string; DisplayName: string; ServiceName?: string | null; RpId: string; Username?: string | null }) => ({
|
||||
id: pk.Id,
|
||||
displayName: pk.DisplayName,
|
||||
serviceName: pk.ServiceName,
|
||||
@@ -147,7 +147,7 @@ const PasskeyAuthenticate: React.FC = () => {
|
||||
|
||||
try {
|
||||
// Get the stored passkey from vault
|
||||
const storedPasskey = dbContext.sqliteClient.getPasskeyById(passkeyId);
|
||||
const storedPasskey = dbContext.sqliteClient.passkeys.getById(passkeyId);
|
||||
if (!storedPasskey) {
|
||||
throw new Error(t('common.errors.unknownError'));
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ const PasskeyCreate: React.FC = () => {
|
||||
|
||||
// Check for existing passkeys for this RP ID and user
|
||||
if (dbContext.sqliteClient && data.publicKey?.rp?.id) {
|
||||
const allPasskeysForRpId = dbContext.sqliteClient.getPasskeysByRpId(data.publicKey.rp.id);
|
||||
const allPasskeysForRpId = dbContext.sqliteClient.passkeys.getByRpId(data.publicKey.rp.id);
|
||||
|
||||
/**
|
||||
* Filter by user ID and/or username if provided
|
||||
@@ -93,7 +93,7 @@ const PasskeyCreate: React.FC = () => {
|
||||
let filtered = allPasskeysForRpId;
|
||||
|
||||
if (data.publicKey.user?.id || data.publicKey.user?.name) {
|
||||
filtered = allPasskeysForRpId.filter(passkey => {
|
||||
filtered = allPasskeysForRpId.filter((passkey) => {
|
||||
/**
|
||||
* Match by user handle if both are available
|
||||
* The request has base64url encoded user.id, passkey has UserHandle as byte array
|
||||
@@ -131,7 +131,7 @@ const PasskeyCreate: React.FC = () => {
|
||||
// If no existing passkeys for this user, check for matching items
|
||||
if (filtered.length === 0) {
|
||||
// Get all items and filter for matches
|
||||
const allItems = dbContext.sqliteClient.getAllItems();
|
||||
const allItems = dbContext.sqliteClient.items.getAll();
|
||||
|
||||
/*
|
||||
* Filter items that:
|
||||
@@ -139,7 +139,7 @@ const PasskeyCreate: React.FC = () => {
|
||||
* 2. Have username/password (are login items)
|
||||
* 3. Don't already have a passkey
|
||||
*/
|
||||
const itemsWithoutPasskeys = allItems.filter(item => {
|
||||
const itemsWithoutPasskeys = allItems.filter((item) => {
|
||||
// Must have username or password to be a login item
|
||||
const username = getFieldValue(item, FieldKey.LoginUsername);
|
||||
const password = getFieldValue(item, FieldKey.LoginPassword);
|
||||
@@ -313,19 +313,19 @@ const PasskeyCreate: React.FC = () => {
|
||||
await executeVaultMutationAsync(async () => {
|
||||
if (selectedPasskeyToReplace) {
|
||||
// Replace existing passkey: update the item and passkey
|
||||
const existingPasskey = dbContext.sqliteClient!.getPasskeyById(selectedPasskeyToReplace);
|
||||
const existingPasskey = dbContext.sqliteClient!.passkeys.getById(selectedPasskeyToReplace);
|
||||
if (existingPasskey) {
|
||||
// Get existing item to preserve its data
|
||||
const existingItem = dbContext.sqliteClient!.getItemById(existingPasskey.ItemId);
|
||||
const existingItem = dbContext.sqliteClient!.items.getById(existingPasskey.ItemId);
|
||||
if (existingItem) {
|
||||
// Update the parent item with new favicon and user-provided display name
|
||||
await dbContext.sqliteClient!.updateItem(
|
||||
await dbContext.sqliteClient!.items.update(
|
||||
{
|
||||
...existingItem,
|
||||
Name: displayName,
|
||||
Logo: faviconLogo ?? existingItem.Logo,
|
||||
Fields: [
|
||||
...(existingItem.Fields || []).filter(f => f.FieldKey !== FieldKey.LoginUrl && f.FieldKey !== FieldKey.LoginUsername),
|
||||
...(existingItem.Fields || []).filter((f) => f.FieldKey !== FieldKey.LoginUrl && f.FieldKey !== FieldKey.LoginUsername),
|
||||
{ FieldKey: FieldKey.LoginUrl, Label: 'URL', FieldType: FieldTypes.URL, Value: request.origin, IsHidden: false, DisplayOrder: 0 },
|
||||
{ FieldKey: FieldKey.LoginUsername, Label: 'Username', FieldType: FieldTypes.Text, Value: request.publicKey.user.name, IsHidden: false, DisplayOrder: 1 }
|
||||
]
|
||||
@@ -336,7 +336,7 @@ const PasskeyCreate: React.FC = () => {
|
||||
}
|
||||
|
||||
// Delete the old passkey
|
||||
await dbContext.sqliteClient!.deletePasskeyById(selectedPasskeyToReplace);
|
||||
await dbContext.sqliteClient!.passkeys.deleteById(selectedPasskeyToReplace);
|
||||
|
||||
/**
|
||||
* Create new passkey with same item
|
||||
@@ -352,7 +352,7 @@ const PasskeyCreate: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.sqliteClient!.createPasskey({
|
||||
await dbContext.sqliteClient!.passkeys.create({
|
||||
Id: newPasskeyGuid,
|
||||
ItemId: existingPasskey.ItemId,
|
||||
RpId: stored.rpId,
|
||||
@@ -380,7 +380,7 @@ const PasskeyCreate: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.sqliteClient!.createPasskey({
|
||||
await dbContext.sqliteClient!.passkeys.create({
|
||||
Id: newPasskeyGuid,
|
||||
ItemId: selectedItemToAttach,
|
||||
RpId: stored.rpId,
|
||||
@@ -406,7 +406,7 @@ const PasskeyCreate: React.FC = () => {
|
||||
UpdatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
const itemId = await dbContext.sqliteClient!.createItem(newItem, []);
|
||||
const itemId = await dbContext.sqliteClient!.items.create(newItem, []);
|
||||
|
||||
/**
|
||||
* Create the Passkey linked to the item
|
||||
@@ -423,7 +423,7 @@ const PasskeyCreate: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.sqliteClient!.createPasskey({
|
||||
await dbContext.sqliteClient!.passkeys.create({
|
||||
Id: newPasskeyGuid,
|
||||
ItemId: itemId,
|
||||
RpId: stored.rpId,
|
||||
|
||||
@@ -111,7 +111,7 @@ export class FaviconService {
|
||||
return false;
|
||||
}
|
||||
|
||||
return sqliteClient.hasLogoForSource(source);
|
||||
return sqliteClient.logos.hasLogoForSource(source);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,7 +142,7 @@ export class FaviconService {
|
||||
}
|
||||
|
||||
// Check if logo already exists (deduplication)
|
||||
if (sqliteClient.hasLogoForSource(source)) {
|
||||
if (sqliteClient.logos.hasLogoForSource(source)) {
|
||||
return { success: false, skipped: true };
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
137
apps/browser-extension/src/utils/db/BaseRepository.ts
Normal file
137
apps/browser-extension/src/utils/db/BaseRepository.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { Database } from 'sql.js';
|
||||
|
||||
import * as dateFormatter from '@/utils/dateFormatter';
|
||||
|
||||
export type SqliteBindValue = string | number | null | Uint8Array;
|
||||
|
||||
/**
|
||||
* Interface for the core database operations needed by repositories.
|
||||
*/
|
||||
export interface IDatabaseClient {
|
||||
getDb(): Database | null;
|
||||
executeQuery<T>(query: string, params?: SqliteBindValue[]): T[];
|
||||
executeUpdate(query: string, params?: SqliteBindValue[]): number;
|
||||
beginTransaction(): void;
|
||||
commitTransaction(): Promise<void>;
|
||||
rollbackTransaction(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base repository class with common database operations.
|
||||
* Provides transaction handling, soft delete, and other shared functionality.
|
||||
*/
|
||||
export abstract class BaseRepository {
|
||||
constructor(protected client: IDatabaseClient) {}
|
||||
|
||||
/**
|
||||
* Execute a function within a transaction.
|
||||
* Automatically handles begin, commit, and rollback.
|
||||
* @param fn - The function to execute within the transaction
|
||||
* @returns The result of the function
|
||||
*/
|
||||
protected async withTransaction<T>(fn: () => T | Promise<T>): Promise<T> {
|
||||
this.client.beginTransaction();
|
||||
try {
|
||||
const result = await fn();
|
||||
await this.client.commitTransaction();
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.client.rollbackTransaction();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete a record by setting IsDeleted = 1.
|
||||
* @param table - The table name
|
||||
* @param id - The record ID
|
||||
* @returns Number of rows affected
|
||||
*/
|
||||
protected softDelete(table: string, id: string): number {
|
||||
const now = dateFormatter.now();
|
||||
return this.client.executeUpdate(
|
||||
`UPDATE ${table} SET IsDeleted = 1, UpdatedAt = ? WHERE Id = ?`,
|
||||
[now, id]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete records by a foreign key.
|
||||
* @param table - The table name
|
||||
* @param foreignKey - The foreign key column name
|
||||
* @param foreignKeyValue - The foreign key value
|
||||
* @returns Number of rows affected
|
||||
*/
|
||||
protected softDeleteByForeignKey(table: string, foreignKey: string, foreignKeyValue: string): number {
|
||||
const now = dateFormatter.now();
|
||||
return this.client.executeUpdate(
|
||||
`UPDATE ${table} SET IsDeleted = 1, UpdatedAt = ? WHERE ${foreignKey} = ?`,
|
||||
[now, foreignKeyValue]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard delete a record permanently.
|
||||
* @param table - The table name
|
||||
* @param id - The record ID
|
||||
* @returns Number of rows affected
|
||||
*/
|
||||
protected hardDelete(table: string, id: string): number {
|
||||
return this.client.executeUpdate(`DELETE FROM ${table} WHERE Id = ?`, [id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard delete records by a foreign key.
|
||||
* @param table - The table name
|
||||
* @param foreignKey - The foreign key column name
|
||||
* @param foreignKeyValue - The foreign key value
|
||||
* @returns Number of rows affected
|
||||
*/
|
||||
protected hardDeleteByForeignKey(table: string, foreignKey: string, foreignKeyValue: string): number {
|
||||
return this.client.executeUpdate(
|
||||
`DELETE FROM ${table} WHERE ${foreignKey} = ?`,
|
||||
[foreignKeyValue]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a table exists in the database.
|
||||
* @param tableName - The name of the table to check
|
||||
* @returns True if the table exists
|
||||
*/
|
||||
protected tableExists(tableName: string): boolean {
|
||||
const results = this.client.executeQuery<{ name: string }>(
|
||||
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
|
||||
[tableName]
|
||||
);
|
||||
return results.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new UUID in uppercase format.
|
||||
* @returns A new UUID string
|
||||
*/
|
||||
protected generateId(): string {
|
||||
return crypto.randomUUID().toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current timestamp in the standard format.
|
||||
* @returns Current timestamp string
|
||||
*/
|
||||
protected now(): string {
|
||||
return dateFormatter.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a parameterized IN clause for SQL queries.
|
||||
* @param values - Array of values for the IN clause
|
||||
* @returns Object with placeholders string and values array
|
||||
*/
|
||||
protected buildInClause(values: string[]): { placeholders: string; values: string[] } {
|
||||
return {
|
||||
placeholders: values.map(() => '?').join(','),
|
||||
values
|
||||
};
|
||||
}
|
||||
}
|
||||
22
apps/browser-extension/src/utils/db/index.ts
Normal file
22
apps/browser-extension/src/utils/db/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Base
|
||||
export { BaseRepository, type IDatabaseClient, type SqliteBindValue } from './BaseRepository';
|
||||
|
||||
// Mappers
|
||||
export { FieldMapper, type FieldRow } from './mappers/FieldMapper';
|
||||
export { ItemMapper, type ItemRow, type TagRow } from './mappers/ItemMapper';
|
||||
export { PasskeyMapper, type PasskeyRow, type PasskeyWithItemRow, type PasskeyWithItem } from './mappers/PasskeyMapper';
|
||||
|
||||
// Queries
|
||||
export {
|
||||
ItemQueries,
|
||||
FieldValueQueries,
|
||||
FieldDefinitionQueries,
|
||||
FieldHistoryQueries
|
||||
} from './queries/ItemQueries';
|
||||
|
||||
// Repositories
|
||||
export { ItemRepository } from './repositories/ItemRepository';
|
||||
export { PasskeyRepository } from './repositories/PasskeyRepository';
|
||||
export { FolderRepository, type Folder } from './repositories/FolderRepository';
|
||||
export { SettingsRepository } from './repositories/SettingsRepository';
|
||||
export { LogoRepository } from './repositories/LogoRepository';
|
||||
191
apps/browser-extension/src/utils/db/mappers/FieldMapper.ts
Normal file
191
apps/browser-extension/src/utils/db/mappers/FieldMapper.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { ItemField, FieldType } from '@/utils/dist/core/models/vault';
|
||||
import { FieldTypes, getSystemField } from '@/utils/dist/core/models/vault';
|
||||
|
||||
/**
|
||||
* Raw field row from database query.
|
||||
*/
|
||||
export interface FieldRow {
|
||||
ItemId: string;
|
||||
FieldKey: string | null;
|
||||
FieldDefinitionId: string | null;
|
||||
CustomLabel: string | null;
|
||||
CustomFieldType: string | null;
|
||||
CustomIsHidden: number | null;
|
||||
Value: string;
|
||||
DisplayOrder: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intermediate field representation before grouping.
|
||||
*/
|
||||
interface ProcessedField {
|
||||
ItemId: string;
|
||||
FieldKey: string;
|
||||
Label: string;
|
||||
FieldType: string;
|
||||
IsHidden: number;
|
||||
Value: string;
|
||||
DisplayOrder: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapper class for processing database field rows into ItemField objects.
|
||||
* Handles both system fields (with FieldKey) and custom fields (with FieldDefinitionId).
|
||||
*/
|
||||
export class FieldMapper {
|
||||
/**
|
||||
* Process raw field rows from database into a map of ItemId -> ItemField[].
|
||||
* Handles system vs custom fields and multi-value field grouping.
|
||||
* @param rows - Raw field rows from database
|
||||
* @returns Map of ItemId to array of ItemField objects
|
||||
*/
|
||||
public static processFieldRows(rows: FieldRow[]): Map<string, ItemField[]> {
|
||||
// First, convert rows to processed fields with proper metadata
|
||||
const processedFields = rows.map(row => this.processFieldRow(row));
|
||||
|
||||
// Group by ItemId and FieldKey (to handle multi-value fields)
|
||||
const fieldsByItem = new Map<string, ItemField[]>();
|
||||
const fieldValuesByKey = new Map<string, string[]>();
|
||||
|
||||
for (const field of processedFields) {
|
||||
const key = `${field.ItemId}_${field.FieldKey}`;
|
||||
|
||||
// Accumulate values for the same field
|
||||
if (!fieldValuesByKey.has(key)) {
|
||||
fieldValuesByKey.set(key, []);
|
||||
}
|
||||
fieldValuesByKey.get(key)!.push(field.Value);
|
||||
|
||||
// Create ItemField entry only once per unique FieldKey per item
|
||||
if (!fieldsByItem.has(field.ItemId)) {
|
||||
fieldsByItem.set(field.ItemId, []);
|
||||
}
|
||||
|
||||
const itemFields = fieldsByItem.get(field.ItemId)!;
|
||||
const existingField = itemFields.find(f => f.FieldKey === field.FieldKey);
|
||||
|
||||
if (!existingField) {
|
||||
itemFields.push({
|
||||
FieldKey: field.FieldKey,
|
||||
Label: field.Label,
|
||||
FieldType: field.FieldType as FieldType,
|
||||
Value: '', // Will be set below
|
||||
IsHidden: field.IsHidden === 1,
|
||||
DisplayOrder: field.DisplayOrder
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Set Values (single value or array for multi-value fields)
|
||||
for (const [itemId, fields] of fieldsByItem) {
|
||||
for (const field of fields) {
|
||||
const key = `${itemId}_${field.FieldKey}`;
|
||||
const values = fieldValuesByKey.get(key) || [];
|
||||
|
||||
if (values.length === 1) {
|
||||
field.Value = values[0];
|
||||
} else {
|
||||
field.Value = values;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fieldsByItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single field row to extract proper metadata.
|
||||
* System fields use FieldKey and get metadata from SystemFieldRegistry.
|
||||
* Custom fields use FieldDefinitionId and get metadata from the row.
|
||||
* @param row - Raw field row
|
||||
* @returns Processed field with proper metadata
|
||||
*/
|
||||
private static processFieldRow(row: FieldRow): ProcessedField {
|
||||
if (row.FieldKey) {
|
||||
// System field: has FieldKey, get metadata from SystemFieldRegistry
|
||||
const systemField = getSystemField(row.FieldKey);
|
||||
return {
|
||||
ItemId: row.ItemId,
|
||||
FieldKey: row.FieldKey,
|
||||
Label: row.FieldKey, // Use FieldKey as label; UI layer translates via fieldLabels.*
|
||||
FieldType: systemField?.FieldType || FieldTypes.Text,
|
||||
IsHidden: systemField?.IsHidden ? 1 : 0,
|
||||
Value: row.Value,
|
||||
DisplayOrder: row.DisplayOrder
|
||||
};
|
||||
} else {
|
||||
// Custom field: has FieldDefinitionId, get metadata from FieldDefinitions
|
||||
return {
|
||||
ItemId: row.ItemId,
|
||||
FieldKey: row.FieldDefinitionId || '', // Use FieldDefinitionId as the key for custom fields
|
||||
Label: row.CustomLabel || '',
|
||||
FieldType: row.CustomFieldType || FieldTypes.Text,
|
||||
IsHidden: row.CustomIsHidden || 0,
|
||||
Value: row.Value,
|
||||
DisplayOrder: row.DisplayOrder
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process field rows for a single item (without ItemId in result).
|
||||
* Used when fetching a single item by ID.
|
||||
* @param rows - Raw field rows for a single item
|
||||
* @returns Array of ItemField objects
|
||||
*/
|
||||
public static processFieldRowsForSingleItem(rows: Omit<FieldRow, 'ItemId'>[]): ItemField[] {
|
||||
const fieldValuesByKey = new Map<string, string[]>();
|
||||
const uniqueFields = new Map<string, {
|
||||
FieldKey: string;
|
||||
Label: string;
|
||||
FieldType: string;
|
||||
IsHidden: number;
|
||||
DisplayOrder: number;
|
||||
}>();
|
||||
|
||||
for (const row of rows) {
|
||||
const fieldKey = row.FieldKey || row.FieldDefinitionId || '';
|
||||
|
||||
// Accumulate values
|
||||
if (!fieldValuesByKey.has(fieldKey)) {
|
||||
fieldValuesByKey.set(fieldKey, []);
|
||||
}
|
||||
fieldValuesByKey.get(fieldKey)!.push(row.Value);
|
||||
|
||||
// Store field metadata (only once per FieldKey)
|
||||
if (!uniqueFields.has(fieldKey)) {
|
||||
if (row.FieldKey) {
|
||||
// System field
|
||||
const systemField = getSystemField(row.FieldKey);
|
||||
uniqueFields.set(fieldKey, {
|
||||
FieldKey: row.FieldKey,
|
||||
Label: row.FieldKey, // Use FieldKey as label; UI layer translates via fieldLabels.*
|
||||
FieldType: systemField?.FieldType || FieldTypes.Text,
|
||||
IsHidden: systemField?.IsHidden ? 1 : 0,
|
||||
DisplayOrder: row.DisplayOrder
|
||||
});
|
||||
} else {
|
||||
// Custom field
|
||||
uniqueFields.set(fieldKey, {
|
||||
FieldKey: fieldKey,
|
||||
Label: row.CustomLabel || '',
|
||||
FieldType: row.CustomFieldType || FieldTypes.Text,
|
||||
IsHidden: row.CustomIsHidden || 0,
|
||||
DisplayOrder: row.DisplayOrder
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build fields array with proper single/multi values
|
||||
return Array.from(uniqueFields.entries()).map(([fieldKey, metadata]) => {
|
||||
const values = fieldValuesByKey.get(fieldKey) || [];
|
||||
return {
|
||||
...metadata,
|
||||
FieldType: metadata.FieldType as FieldType,
|
||||
Value: values.length === 1 ? values[0] : values,
|
||||
IsHidden: metadata.IsHidden === 1
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
149
apps/browser-extension/src/utils/db/mappers/ItemMapper.ts
Normal file
149
apps/browser-extension/src/utils/db/mappers/ItemMapper.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { Item, ItemField, ItemTagRef, ItemType } from '@/utils/dist/core/models/vault';
|
||||
|
||||
/**
|
||||
* Item with optional DeletedAt field for recently deleted items.
|
||||
*/
|
||||
export type ItemWithDeletedAt = Item & { DeletedAt?: string };
|
||||
|
||||
/**
|
||||
* Raw item row from database query.
|
||||
*/
|
||||
export interface ItemRow {
|
||||
Id: string;
|
||||
Name: string;
|
||||
ItemType: string;
|
||||
FolderId: string | null;
|
||||
FolderPath: string | null;
|
||||
Logo: Uint8Array | null;
|
||||
HasPasskey: number;
|
||||
HasAttachment: number;
|
||||
HasTotp: number;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
DeletedAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw tag row from database query.
|
||||
*/
|
||||
export interface TagRow {
|
||||
ItemId: string;
|
||||
Id: string;
|
||||
Name: string;
|
||||
Color: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapper class for converting database rows to Item objects.
|
||||
*/
|
||||
export class ItemMapper {
|
||||
/**
|
||||
* Map a single database row to an Item object.
|
||||
* @param row - Raw item row from database
|
||||
* @param fields - Processed fields for this item
|
||||
* @param tags - Tags for this item
|
||||
* @returns Item object
|
||||
*/
|
||||
public static mapRow(
|
||||
row: ItemRow,
|
||||
fields: ItemField[] = [],
|
||||
tags: ItemTagRef[] = []
|
||||
): Item {
|
||||
return {
|
||||
Id: row.Id,
|
||||
Name: row.Name,
|
||||
ItemType: row.ItemType as ItemType,
|
||||
Logo: row.Logo ?? undefined,
|
||||
FolderId: row.FolderId,
|
||||
FolderPath: row.FolderPath || null,
|
||||
Tags: tags,
|
||||
Fields: fields,
|
||||
HasPasskey: row.HasPasskey === 1,
|
||||
HasAttachment: row.HasAttachment === 1,
|
||||
HasTotp: row.HasTotp === 1,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map multiple database rows to Item objects with their fields and tags.
|
||||
* @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
|
||||
* @returns Array of Item objects
|
||||
*/
|
||||
public static mapRows(
|
||||
rows: ItemRow[],
|
||||
fieldsByItem: Map<string, ItemField[]>,
|
||||
tagsByItem: Map<string, ItemTagRef[]>
|
||||
): Item[] {
|
||||
return rows.map(row => this.mapRow(
|
||||
row,
|
||||
fieldsByItem.get(row.Id) || [],
|
||||
tagsByItem.get(row.Id) || []
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Group tag rows by ItemId into a map.
|
||||
* @param tagRows - Raw tag rows from database
|
||||
* @returns Map of ItemId to array of ItemTagRef
|
||||
*/
|
||||
public static groupTagsByItem(tagRows: TagRow[]): Map<string, ItemTagRef[]> {
|
||||
const tagsByItem = new Map<string, ItemTagRef[]>();
|
||||
|
||||
for (const tag of tagRows) {
|
||||
if (!tagsByItem.has(tag.ItemId)) {
|
||||
tagsByItem.set(tag.ItemId, []);
|
||||
}
|
||||
tagsByItem.get(tag.ItemId)!.push({
|
||||
Id: tag.Id,
|
||||
Name: tag.Name,
|
||||
Color: tag.Color || undefined
|
||||
});
|
||||
}
|
||||
|
||||
return tagsByItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map tag rows to ItemTagRef array (for single item).
|
||||
* @param tagRows - Raw tag rows without ItemId
|
||||
* @returns Array of ItemTagRef
|
||||
*/
|
||||
public static mapTagRows(tagRows: Omit<TagRow, 'ItemId'>[]): ItemTagRef[] {
|
||||
return tagRows.map(tag => ({
|
||||
Id: tag.Id,
|
||||
Name: tag.Name,
|
||||
Color: tag.Color || undefined
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @returns Item object with DeletedAt
|
||||
*/
|
||||
public static mapDeletedItemRow(
|
||||
row: ItemRow & { DeletedAt: string },
|
||||
fields: ItemField[] = []
|
||||
): ItemWithDeletedAt {
|
||||
return {
|
||||
Id: row.Id,
|
||||
Name: row.Name,
|
||||
ItemType: row.ItemType as ItemType,
|
||||
Logo: row.Logo ? new Uint8Array(row.Logo) : undefined,
|
||||
FolderId: row.FolderId,
|
||||
FolderPath: row.FolderPath,
|
||||
DeletedAt: row.DeletedAt,
|
||||
HasPasskey: row.HasPasskey === 1,
|
||||
HasAttachment: row.HasAttachment === 1,
|
||||
HasTotp: row.HasTotp === 1,
|
||||
Fields: fields,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
102
apps/browser-extension/src/utils/db/mappers/PasskeyMapper.ts
Normal file
102
apps/browser-extension/src/utils/db/mappers/PasskeyMapper.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { Passkey } from '@/utils/dist/core/models/vault';
|
||||
|
||||
/**
|
||||
* Raw passkey row from database query.
|
||||
*/
|
||||
export interface PasskeyRow {
|
||||
Id: string;
|
||||
ItemId: string;
|
||||
RpId: string;
|
||||
UserHandle: Uint8Array | null;
|
||||
PublicKey: string;
|
||||
PrivateKey: string;
|
||||
DisplayName: string;
|
||||
PrfKey: Uint8Array | null;
|
||||
AdditionalData: string | null;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
IsDeleted: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended passkey row with item information.
|
||||
*/
|
||||
export interface PasskeyWithItemRow extends PasskeyRow {
|
||||
Username?: string | null;
|
||||
ServiceName?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passkey with optional item information.
|
||||
*/
|
||||
export type PasskeyWithItem = Passkey & {
|
||||
Username?: string | null;
|
||||
ServiceName?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a date string to epoch milliseconds.
|
||||
* @param dateString - Date string in ISO format
|
||||
* @returns Epoch milliseconds
|
||||
*/
|
||||
function dateToEpoch(dateString: string): number {
|
||||
return new Date(dateString).getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapper class for converting database rows to Passkey objects.
|
||||
*/
|
||||
export class PasskeyMapper {
|
||||
/**
|
||||
* Map a single database row to a Passkey object.
|
||||
* @param row - Raw passkey row from database
|
||||
* @returns Passkey object
|
||||
*/
|
||||
public static mapRow(row: PasskeyRow): Passkey {
|
||||
return {
|
||||
Id: row.Id,
|
||||
ItemId: row.ItemId,
|
||||
RpId: row.RpId,
|
||||
UserHandle: row.UserHandle ?? undefined,
|
||||
PublicKey: row.PublicKey,
|
||||
PrivateKey: row.PrivateKey,
|
||||
DisplayName: row.DisplayName,
|
||||
PrfKey: row.PrfKey ?? undefined,
|
||||
AdditionalData: row.AdditionalData,
|
||||
CreatedAt: dateToEpoch(row.CreatedAt),
|
||||
UpdatedAt: dateToEpoch(row.UpdatedAt),
|
||||
IsDeleted: row.IsDeleted
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a single database row to a Passkey with item information.
|
||||
* @param row - Raw passkey row with item data
|
||||
* @returns Passkey with Username and ServiceName
|
||||
*/
|
||||
public static mapRowWithItem(row: PasskeyWithItemRow): PasskeyWithItem {
|
||||
return {
|
||||
...this.mapRow(row),
|
||||
Username: row.Username,
|
||||
ServiceName: row.ServiceName
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map multiple database rows to Passkey objects.
|
||||
* @param rows - Raw passkey rows from database
|
||||
* @returns Array of Passkey objects
|
||||
*/
|
||||
public static mapRows(rows: PasskeyRow[]): Passkey[] {
|
||||
return rows.map(row => this.mapRow(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map multiple database rows to Passkey objects with item information.
|
||||
* @param rows - Raw passkey rows with item data
|
||||
* @returns Array of Passkey with Username and ServiceName
|
||||
*/
|
||||
public static mapRowsWithItem(rows: PasskeyWithItemRow[]): PasskeyWithItem[] {
|
||||
return rows.map(row => this.mapRowWithItem(row));
|
||||
}
|
||||
}
|
||||
352
apps/browser-extension/src/utils/db/queries/ItemQueries.ts
Normal file
352
apps/browser-extension/src/utils/db/queries/ItemQueries.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { FieldKey } from '@/utils/dist/core/models/vault';
|
||||
|
||||
/**
|
||||
* SQL query constants for Item operations.
|
||||
* Centralizes all item-related queries to avoid duplication.
|
||||
*/
|
||||
export class ItemQueries {
|
||||
/**
|
||||
* Base SELECT for items with common fields.
|
||||
* Includes LEFT JOIN to Logos and Folders, and subqueries for HasPasskey/HasAttachment/HasTotp.
|
||||
*/
|
||||
public static readonly BASE_SELECT = `
|
||||
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,
|
||||
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`;
|
||||
|
||||
/**
|
||||
* Get all active items (not deleted, not in trash).
|
||||
*/
|
||||
public static readonly GET_ALL_ACTIVE = `
|
||||
${ItemQueries.BASE_SELECT}
|
||||
WHERE i.IsDeleted = 0 AND i.DeletedAt IS NULL
|
||||
ORDER BY i.CreatedAt DESC`;
|
||||
|
||||
/**
|
||||
* Get a single item by ID.
|
||||
*/
|
||||
public static readonly GET_BY_ID = `
|
||||
SELECT
|
||||
i.Id,
|
||||
i.Name,
|
||||
i.ItemType,
|
||||
i.FolderId,
|
||||
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,
|
||||
i.CreatedAt,
|
||||
i.UpdatedAt
|
||||
FROM Items i
|
||||
LEFT JOIN Logos l ON i.LogoId = l.Id
|
||||
WHERE i.Id = ? AND i.IsDeleted = 0`;
|
||||
|
||||
/**
|
||||
* Get all recently deleted items (in trash).
|
||||
*/
|
||||
public static readonly GET_RECENTLY_DELETED = `
|
||||
${ItemQueries.BASE_SELECT},
|
||||
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`;
|
||||
|
||||
/**
|
||||
* Count of recently deleted items.
|
||||
*/
|
||||
public static readonly COUNT_RECENTLY_DELETED = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM Items
|
||||
WHERE IsDeleted = 0 AND DeletedAt IS NOT NULL`;
|
||||
|
||||
/**
|
||||
* Get field values for multiple items.
|
||||
* @param itemCount - Number of items (for placeholder generation)
|
||||
* @returns Query with placeholders
|
||||
*/
|
||||
public static getFieldValuesForItems(itemCount: number): string {
|
||||
const placeholders = Array(itemCount).fill('?').join(',');
|
||||
return `
|
||||
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 (${placeholders})
|
||||
AND fv.IsDeleted = 0
|
||||
ORDER BY fv.ItemId, fv.Weight`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field values for a single item.
|
||||
*/
|
||||
public static readonly GET_FIELD_VALUES_FOR_ITEM = `
|
||||
SELECT
|
||||
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 = ? AND fv.IsDeleted = 0
|
||||
ORDER BY fv.Weight`;
|
||||
|
||||
/**
|
||||
* Get tags for multiple items.
|
||||
* @param itemCount - Number of items (for placeholder generation)
|
||||
* @returns Query with placeholders
|
||||
*/
|
||||
public static getTagsForItems(itemCount: number): string {
|
||||
const placeholders = Array(itemCount).fill('?').join(',');
|
||||
return `
|
||||
SELECT
|
||||
it.ItemId,
|
||||
t.Id,
|
||||
t.Name,
|
||||
t.Color
|
||||
FROM ItemTags it
|
||||
INNER JOIN Tags t ON it.TagId = t.Id
|
||||
WHERE it.ItemId IN (${placeholders})
|
||||
AND it.IsDeleted = 0
|
||||
AND t.IsDeleted = 0
|
||||
ORDER BY t.DisplayOrder, t.Name`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags for a single item.
|
||||
*/
|
||||
public static readonly GET_TAGS_FOR_ITEM = `
|
||||
SELECT
|
||||
t.Id,
|
||||
t.Name,
|
||||
t.Color
|
||||
FROM ItemTags it
|
||||
INNER JOIN Tags t ON it.TagId = t.Id
|
||||
WHERE it.ItemId = ? AND it.IsDeleted = 0 AND t.IsDeleted = 0
|
||||
ORDER BY t.DisplayOrder, t.Name`;
|
||||
|
||||
/**
|
||||
* Insert a new item.
|
||||
*/
|
||||
public static readonly INSERT_ITEM = `
|
||||
INSERT INTO Items (Id, Name, ItemType, LogoId, FolderId, CreatedAt, UpdatedAt, IsDeleted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
/**
|
||||
* Update an existing item.
|
||||
*/
|
||||
public static readonly UPDATE_ITEM = `
|
||||
UPDATE Items
|
||||
SET Name = ?,
|
||||
ItemType = ?,
|
||||
FolderId = ?,
|
||||
LogoId = COALESCE(?, LogoId),
|
||||
UpdatedAt = ?
|
||||
WHERE Id = ?`;
|
||||
|
||||
/**
|
||||
* Move item to trash (set DeletedAt).
|
||||
*/
|
||||
public static readonly TRASH_ITEM = `
|
||||
UPDATE Items
|
||||
SET DeletedAt = ?,
|
||||
UpdatedAt = ?
|
||||
WHERE Id = ? AND IsDeleted = 0`;
|
||||
|
||||
/**
|
||||
* Restore item from trash (clear DeletedAt).
|
||||
*/
|
||||
public static readonly RESTORE_ITEM = `
|
||||
UPDATE Items
|
||||
SET DeletedAt = NULL,
|
||||
UpdatedAt = ?
|
||||
WHERE Id = ? AND IsDeleted = 0 AND DeletedAt IS NOT NULL`;
|
||||
|
||||
/**
|
||||
* Convert item to tombstone for permanent deletion.
|
||||
*/
|
||||
public static readonly TOMBSTONE_ITEM = `
|
||||
UPDATE Items
|
||||
SET IsDeleted = 1,
|
||||
Name = NULL,
|
||||
LogoId = NULL,
|
||||
FolderId = NULL,
|
||||
UpdatedAt = ?
|
||||
WHERE Id = ?`;
|
||||
|
||||
/**
|
||||
* Get LogoId for an item.
|
||||
*/
|
||||
public static readonly GET_LOGO_ID = `
|
||||
SELECT LogoId FROM Items WHERE Id = ?`;
|
||||
|
||||
/**
|
||||
* Get all unique email addresses from field values.
|
||||
*/
|
||||
public static readonly GET_ALL_EMAIL_ADDRESSES = `
|
||||
SELECT DISTINCT fv.Value as Email
|
||||
FROM FieldValues fv
|
||||
INNER JOIN Items i ON fv.ItemId = i.Id
|
||||
WHERE fv.FieldKey = ?
|
||||
AND fv.Value IS NOT NULL
|
||||
AND fv.Value != ''
|
||||
AND fv.IsDeleted = 0
|
||||
AND i.IsDeleted = 0`;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL query constants for FieldValue operations.
|
||||
*/
|
||||
export class FieldValueQueries {
|
||||
/**
|
||||
* Get existing field values for an item.
|
||||
*/
|
||||
public static readonly GET_EXISTING_FOR_ITEM = `
|
||||
SELECT Id, FieldKey, FieldDefinitionId, Value
|
||||
FROM FieldValues
|
||||
WHERE ItemId = ? AND IsDeleted = 0`;
|
||||
|
||||
/**
|
||||
* Insert a new field value.
|
||||
*/
|
||||
public static readonly INSERT = `
|
||||
INSERT INTO FieldValues (Id, ItemId, FieldDefinitionId, FieldKey, Value, Weight, CreatedAt, UpdatedAt, IsDeleted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
/**
|
||||
* Update an existing field value.
|
||||
*/
|
||||
public static readonly UPDATE = `
|
||||
UPDATE FieldValues
|
||||
SET Value = ?,
|
||||
Weight = ?,
|
||||
UpdatedAt = ?
|
||||
WHERE Id = ?`;
|
||||
|
||||
/**
|
||||
* Soft delete a field value.
|
||||
*/
|
||||
public static readonly SOFT_DELETE = `
|
||||
UPDATE FieldValues
|
||||
SET IsDeleted = 1,
|
||||
UpdatedAt = ?
|
||||
WHERE Id = ?`;
|
||||
|
||||
/**
|
||||
* Get existing field values for history tracking.
|
||||
*/
|
||||
public static readonly GET_FOR_HISTORY = `
|
||||
SELECT FieldKey, Value
|
||||
FROM FieldValues
|
||||
WHERE ItemId = ? AND IsDeleted = 0 AND FieldKey IS NOT NULL`;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL query constants for FieldDefinition operations.
|
||||
*/
|
||||
export class FieldDefinitionQueries {
|
||||
/**
|
||||
* Check if a field definition exists.
|
||||
*/
|
||||
public static readonly EXISTS = `
|
||||
SELECT Id FROM FieldDefinitions WHERE Id = ?`;
|
||||
|
||||
/**
|
||||
* Check if a field definition exists and is not deleted.
|
||||
*/
|
||||
public static readonly EXISTS_ACTIVE = `
|
||||
SELECT Id FROM FieldDefinitions WHERE Id = ? AND IsDeleted = 0`;
|
||||
|
||||
/**
|
||||
* Insert a new field definition.
|
||||
*/
|
||||
public static readonly INSERT = `
|
||||
INSERT INTO FieldDefinitions (Id, FieldType, Label, IsMultiValue, IsHidden, EnableHistory, Weight, ApplicableToTypes, CreatedAt, UpdatedAt, IsDeleted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
/**
|
||||
* Update an existing field definition.
|
||||
*/
|
||||
public static readonly UPDATE = `
|
||||
UPDATE FieldDefinitions
|
||||
SET Label = ?,
|
||||
FieldType = ?,
|
||||
IsHidden = ?,
|
||||
Weight = ?,
|
||||
UpdatedAt = ?
|
||||
WHERE Id = ?`;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL query constants for FieldHistory operations.
|
||||
*/
|
||||
export class FieldHistoryQueries {
|
||||
/**
|
||||
* Insert a history record.
|
||||
*/
|
||||
public static readonly INSERT = `
|
||||
INSERT INTO FieldHistories (Id, ItemId, FieldDefinitionId, FieldKey, ValueSnapshot, ChangedAt, CreatedAt, UpdatedAt, IsDeleted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
/**
|
||||
* Get history records for a field.
|
||||
*/
|
||||
public static readonly GET_FOR_FIELD = `
|
||||
SELECT
|
||||
Id,
|
||||
ItemId,
|
||||
FieldKey,
|
||||
ValueSnapshot,
|
||||
ChangedAt,
|
||||
CreatedAt,
|
||||
UpdatedAt
|
||||
FROM FieldHistories
|
||||
WHERE ItemId = ? AND FieldKey = ? AND IsDeleted = 0
|
||||
ORDER BY ChangedAt DESC
|
||||
LIMIT ?`;
|
||||
|
||||
/**
|
||||
* Get all history records for pruning.
|
||||
*/
|
||||
public static readonly GET_FOR_PRUNING = `
|
||||
SELECT Id, ChangedAt
|
||||
FROM FieldHistories
|
||||
WHERE ItemId = ? AND FieldKey = ? AND IsDeleted = 0
|
||||
ORDER BY ChangedAt DESC`;
|
||||
|
||||
/**
|
||||
* Soft delete old history records.
|
||||
* @param count - Number of records to delete
|
||||
* @returns Query with placeholders
|
||||
*/
|
||||
public static softDeleteOld(count: number): string {
|
||||
const placeholders = Array(count).fill('?').join(',');
|
||||
return `
|
||||
UPDATE FieldHistories
|
||||
SET IsDeleted = 1, UpdatedAt = ?
|
||||
WHERE Id IN (${placeholders})`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { BaseRepository } from '../BaseRepository';
|
||||
|
||||
/**
|
||||
* Folder entity type.
|
||||
*/
|
||||
export interface Folder {
|
||||
Id: string;
|
||||
Name: string;
|
||||
ParentFolderId: string | null;
|
||||
Weight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL query constants for Folder operations.
|
||||
*/
|
||||
const FolderQueries = {
|
||||
/**
|
||||
* Get all active folders.
|
||||
*/
|
||||
GET_ALL: `
|
||||
SELECT Id, Name, ParentFolderId, Weight
|
||||
FROM Folders
|
||||
WHERE IsDeleted = 0
|
||||
ORDER BY Weight, Name`,
|
||||
|
||||
/**
|
||||
* Get folder by ID.
|
||||
*/
|
||||
GET_BY_ID: `
|
||||
SELECT Id, Name, ParentFolderId
|
||||
FROM Folders
|
||||
WHERE Id = ? AND IsDeleted = 0`,
|
||||
|
||||
/**
|
||||
* Insert a new folder.
|
||||
*/
|
||||
INSERT: `
|
||||
INSERT INTO Folders (Id, Name, ParentFolderId, Weight, IsDeleted, CreatedAt, UpdatedAt)
|
||||
VALUES (?, ?, ?, 0, 0, ?, ?)`,
|
||||
|
||||
/**
|
||||
* Update folder name.
|
||||
*/
|
||||
UPDATE_NAME: `
|
||||
UPDATE Folders
|
||||
SET Name = ?,
|
||||
UpdatedAt = ?
|
||||
WHERE Id = ?`,
|
||||
|
||||
/**
|
||||
* Soft delete folder.
|
||||
*/
|
||||
SOFT_DELETE: `
|
||||
UPDATE Folders
|
||||
SET IsDeleted = 1,
|
||||
UpdatedAt = ?
|
||||
WHERE Id = ?`,
|
||||
|
||||
/**
|
||||
* Clear folder reference from items.
|
||||
*/
|
||||
CLEAR_ITEMS_FOLDER: `
|
||||
UPDATE Items
|
||||
SET FolderId = NULL,
|
||||
UpdatedAt = ?
|
||||
WHERE FolderId = ?`,
|
||||
|
||||
/**
|
||||
* Trash items in folder.
|
||||
*/
|
||||
TRASH_ITEMS_IN_FOLDER: `
|
||||
UPDATE Items
|
||||
SET DeletedAt = ?,
|
||||
UpdatedAt = ?,
|
||||
FolderId = NULL
|
||||
WHERE FolderId = ? AND IsDeleted = 0 AND DeletedAt IS NULL`,
|
||||
|
||||
/**
|
||||
* Move item to folder.
|
||||
*/
|
||||
MOVE_ITEM: `
|
||||
UPDATE Items
|
||||
SET FolderId = ?,
|
||||
UpdatedAt = ?
|
||||
WHERE Id = ?`
|
||||
};
|
||||
|
||||
/**
|
||||
* Repository for Folder CRUD operations.
|
||||
*/
|
||||
export class FolderRepository extends BaseRepository {
|
||||
/**
|
||||
* Create a new folder.
|
||||
* @param name - The name of the folder
|
||||
* @param parentFolderId - Optional parent folder ID for nested folders
|
||||
* @returns The ID of the created folder
|
||||
*/
|
||||
public async create(name: string, parentFolderId?: string | null): Promise<string> {
|
||||
return this.withTransaction(async () => {
|
||||
const folderId = crypto.randomUUID();
|
||||
const currentDateTime = this.now();
|
||||
|
||||
this.client.executeUpdate(FolderQueries.INSERT, [
|
||||
folderId,
|
||||
name,
|
||||
parentFolderId || null,
|
||||
currentDateTime,
|
||||
currentDateTime
|
||||
]);
|
||||
|
||||
return folderId;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all folders.
|
||||
* @returns Array of folder objects (empty array if Folders table doesn't exist yet)
|
||||
*/
|
||||
public getAll(): Folder[] {
|
||||
try {
|
||||
return this.client.executeQuery<Folder>(FolderQueries.GET_ALL);
|
||||
} catch (error) {
|
||||
// Table may not exist in older vault versions - return empty array
|
||||
if (error instanceof Error && error.message.includes('no such table')) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a folder by ID.
|
||||
* @param folderId - The ID of the folder
|
||||
* @returns Folder object or null if not found
|
||||
*/
|
||||
public getById(folderId: string): Omit<Folder, 'Weight'> | null {
|
||||
const results = this.client.executeQuery<Omit<Folder, 'Weight'>>(
|
||||
FolderQueries.GET_BY_ID,
|
||||
[folderId]
|
||||
);
|
||||
return results.length > 0 ? results[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a folder's name.
|
||||
* @param folderId - The ID of the folder to update
|
||||
* @param name - The new name for the folder
|
||||
* @returns The number of rows updated
|
||||
*/
|
||||
public async update(folderId: string, name: string): Promise<number> {
|
||||
return this.withTransaction(async () => {
|
||||
const currentDateTime = this.now();
|
||||
return this.client.executeUpdate(FolderQueries.UPDATE_NAME, [
|
||||
name,
|
||||
currentDateTime,
|
||||
folderId
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a folder (soft delete).
|
||||
* Note: Items in the folder will have their FolderId set to NULL.
|
||||
* @param folderId - The ID of the folder to delete
|
||||
* @returns The number of rows updated
|
||||
*/
|
||||
public async delete(folderId: string): Promise<number> {
|
||||
return this.withTransaction(async () => {
|
||||
const currentDateTime = this.now();
|
||||
|
||||
// Remove folder reference from all items in this folder
|
||||
this.client.executeUpdate(FolderQueries.CLEAR_ITEMS_FOLDER, [
|
||||
currentDateTime,
|
||||
folderId
|
||||
]);
|
||||
|
||||
// Soft delete the folder
|
||||
return this.client.executeUpdate(FolderQueries.SOFT_DELETE, [
|
||||
currentDateTime,
|
||||
folderId
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a folder and all items within it (soft delete both folder and items).
|
||||
* Items are moved to "Recently Deleted" (trash).
|
||||
* @param folderId - The ID of the folder to delete
|
||||
* @returns The number of items trashed
|
||||
*/
|
||||
public async deleteWithContents(folderId: string): Promise<number> {
|
||||
return this.withTransaction(async () => {
|
||||
const currentDateTime = this.now();
|
||||
|
||||
// Move all items in this folder to trash and clear FolderId
|
||||
const itemsDeleted = this.client.executeUpdate(FolderQueries.TRASH_ITEMS_IN_FOLDER, [
|
||||
currentDateTime,
|
||||
currentDateTime,
|
||||
folderId
|
||||
]);
|
||||
|
||||
// Soft delete the folder
|
||||
this.client.executeUpdate(FolderQueries.SOFT_DELETE, [
|
||||
currentDateTime,
|
||||
folderId
|
||||
]);
|
||||
|
||||
return itemsDeleted;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an item to a folder.
|
||||
* @param itemId - The ID of the item to move
|
||||
* @param folderId - The ID of the destination folder (null to remove from folder)
|
||||
* @returns The number of rows updated
|
||||
*/
|
||||
public async moveItem(itemId: string, folderId: string | null): Promise<number> {
|
||||
return this.withTransaction(async () => {
|
||||
const currentDateTime = this.now();
|
||||
return this.client.executeUpdate(FolderQueries.MOVE_ITEM, [
|
||||
folderId,
|
||||
currentDateTime,
|
||||
itemId
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
import type { Item, ItemField, Attachment, TotpCode, FieldHistory } from '@/utils/dist/core/models/vault';
|
||||
import { FieldKey, getSystemField, MAX_FIELD_HISTORY_RECORDS } from '@/utils/dist/core/models/vault';
|
||||
|
||||
import { BaseRepository, type IDatabaseClient } from '../BaseRepository';
|
||||
import { FieldMapper, type FieldRow } from '../mappers/FieldMapper';
|
||||
import { ItemMapper, type ItemRow, type TagRow, type ItemWithDeletedAt } from '../mappers/ItemMapper';
|
||||
import {
|
||||
ItemQueries,
|
||||
FieldValueQueries,
|
||||
FieldDefinitionQueries,
|
||||
FieldHistoryQueries
|
||||
} from '../queries/ItemQueries';
|
||||
import type { LogoRepository } from './LogoRepository';
|
||||
|
||||
/**
|
||||
* Repository for Item CRUD operations.
|
||||
* Handles items, field values, field definitions, and field history.
|
||||
*/
|
||||
export class ItemRepository extends BaseRepository {
|
||||
constructor(
|
||||
client: IDatabaseClient,
|
||||
private logoRepository: LogoRepository
|
||||
) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all active items with their dynamic fields and tags.
|
||||
* @returns Array of Item objects (empty array if Items table doesn't exist yet)
|
||||
*/
|
||||
public getAll(): Item[] {
|
||||
let itemRows: ItemRow[];
|
||||
try {
|
||||
itemRows = this.client.executeQuery<ItemRow>(ItemQueries.GET_ALL_ACTIVE);
|
||||
} catch (error) {
|
||||
// Items table may not exist in older vault versions - return empty array
|
||||
if (error instanceof Error && error.message.includes('no such table')) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (itemRows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const itemIds = itemRows.map(i => i.Id);
|
||||
|
||||
// Get all field values
|
||||
const fieldRows = this.client.executeQuery<FieldRow>(
|
||||
ItemQueries.getFieldValuesForItems(itemIds.length),
|
||||
itemIds
|
||||
);
|
||||
const fieldsByItem = FieldMapper.processFieldRows(fieldRows);
|
||||
|
||||
// Get all tags
|
||||
const tagRows = this.client.executeQuery<TagRow>(
|
||||
ItemQueries.getTagsForItems(itemIds.length),
|
||||
itemIds
|
||||
);
|
||||
const tagsByItem = ItemMapper.groupTagsByItem(tagRows);
|
||||
|
||||
return ItemMapper.mapRows(itemRows, fieldsByItem, tagsByItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single item by ID with its dynamic fields and tags.
|
||||
* @param itemId - The ID of the item to fetch
|
||||
* @returns Item object or null if not found
|
||||
*/
|
||||
public getById(itemId: string): Item | null {
|
||||
const results = this.client.executeQuery<ItemRow>(ItemQueries.GET_BY_ID, [itemId]);
|
||||
if (results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get field values
|
||||
const fieldRows = this.client.executeQuery<Omit<FieldRow, 'ItemId'>>(
|
||||
ItemQueries.GET_FIELD_VALUES_FOR_ITEM,
|
||||
[itemId]
|
||||
);
|
||||
const fields = FieldMapper.processFieldRowsForSingleItem(fieldRows);
|
||||
|
||||
// Get tags
|
||||
const tagRows = this.client.executeQuery<Omit<TagRow, 'ItemId'>>(
|
||||
ItemQueries.GET_TAGS_FOR_ITEM,
|
||||
[itemId]
|
||||
);
|
||||
const tags = ItemMapper.mapTagRows(tagRows);
|
||||
|
||||
return ItemMapper.mapRow(results[0], fields, tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all unique email addresses from all items.
|
||||
* @returns Array of email addresses
|
||||
*/
|
||||
public getAllEmailAddresses(): string[] {
|
||||
const results = this.client.executeQuery<{ Email: string }>(
|
||||
ItemQueries.GET_ALL_EMAIL_ADDRESSES,
|
||||
[FieldKey.LoginEmail]
|
||||
);
|
||||
return results.map(row => row.Email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new item with field-based structure.
|
||||
* @param item The item object to insert
|
||||
* @param attachments Optional attachments to associate with the item
|
||||
* @param totpCodes Optional TOTP codes to associate with the item
|
||||
* @returns The ID of the created item
|
||||
*/
|
||||
public async create(
|
||||
item: Item,
|
||||
attachments: Attachment[] = [],
|
||||
totpCodes: TotpCode[] = []
|
||||
): Promise<string> {
|
||||
return this.withTransaction(async () => {
|
||||
const currentDateTime = this.now();
|
||||
const itemId = item.Id || this.generateId();
|
||||
|
||||
// 1. Handle Logo
|
||||
const logoId = this.resolveLogoId(item, currentDateTime);
|
||||
|
||||
// 2. Insert Item
|
||||
this.client.executeUpdate(ItemQueries.INSERT_ITEM, [
|
||||
itemId,
|
||||
item.Name ?? null,
|
||||
item.ItemType,
|
||||
logoId,
|
||||
item.FolderId ?? null,
|
||||
currentDateTime,
|
||||
currentDateTime,
|
||||
0
|
||||
]);
|
||||
|
||||
// 3. Insert FieldValues for all fields
|
||||
if (item.Fields && item.Fields.length > 0) {
|
||||
this.insertFieldValues(itemId, item.Fields, item.ItemType, currentDateTime);
|
||||
}
|
||||
|
||||
// 4. Insert TOTP codes
|
||||
this.insertTotpCodes(itemId, totpCodes, currentDateTime);
|
||||
|
||||
// 5. Insert attachments
|
||||
this.insertAttachments(itemId, attachments, currentDateTime);
|
||||
|
||||
return itemId;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing item with field-based structure.
|
||||
* @param item The item object to update
|
||||
* @param originalAttachmentIds Original attachment IDs for tracking changes
|
||||
* @param attachments Current attachments list
|
||||
* @param originalTotpCodeIds Original TOTP code IDs for tracking changes
|
||||
* @param totpCodes Current TOTP codes list
|
||||
* @returns The number of rows modified
|
||||
*/
|
||||
public async update(
|
||||
item: Item,
|
||||
originalAttachmentIds: string[] = [],
|
||||
attachments: Attachment[] = [],
|
||||
originalTotpCodeIds: string[] = [],
|
||||
totpCodes: TotpCode[] = []
|
||||
): Promise<number> {
|
||||
return this.withTransaction(async () => {
|
||||
const currentDateTime = this.now();
|
||||
|
||||
// 1. Handle Logo
|
||||
const logoId = this.resolveLogoId(item, currentDateTime);
|
||||
|
||||
// 2. Update Item
|
||||
this.client.executeUpdate(ItemQueries.UPDATE_ITEM, [
|
||||
item.Name ?? null,
|
||||
item.ItemType,
|
||||
item.FolderId ?? null,
|
||||
logoId,
|
||||
currentDateTime,
|
||||
item.Id
|
||||
]);
|
||||
|
||||
// 3. Track history for fields that have EnableHistory=true before updating
|
||||
await this.trackFieldHistory(item.Id, item.Fields, currentDateTime);
|
||||
|
||||
// 4. Update field values
|
||||
this.updateFieldValues(item, currentDateTime);
|
||||
|
||||
// 5. Handle TOTP codes
|
||||
this.handleTotpCodes(item.Id, totpCodes, originalTotpCodeIds, currentDateTime);
|
||||
|
||||
// 6. Handle attachments
|
||||
this.handleAttachments(item.Id, attachments, originalAttachmentIds, currentDateTime);
|
||||
|
||||
return 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an item to "Recently Deleted" (trash).
|
||||
* @param itemId - The ID of the item to trash
|
||||
* @returns The number of rows updated
|
||||
*/
|
||||
public async trash(itemId: string): Promise<number> {
|
||||
return this.withTransaction(async () => {
|
||||
const currentDateTime = this.now();
|
||||
return this.client.executeUpdate(ItemQueries.TRASH_ITEM, [
|
||||
currentDateTime,
|
||||
currentDateTime,
|
||||
itemId
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore an item from "Recently Deleted".
|
||||
* @param itemId - The ID of the item to restore
|
||||
* @returns The number of rows updated
|
||||
*/
|
||||
public async restore(itemId: string): Promise<number> {
|
||||
return this.withTransaction(async () => {
|
||||
const currentDateTime = this.now();
|
||||
return this.client.executeUpdate(ItemQueries.RESTORE_ITEM, [
|
||||
currentDateTime,
|
||||
itemId
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete an item - converts to tombstone for sync.
|
||||
* @param itemId - The ID of the item to permanently delete
|
||||
* @returns The number of rows updated
|
||||
*/
|
||||
public async permanentlyDelete(itemId: string): Promise<number> {
|
||||
return this.withTransaction(async () => {
|
||||
const currentDateTime = this.now();
|
||||
|
||||
// Get the LogoId before we clear it
|
||||
const logoResult = this.client.executeQuery<{ LogoId: string | null }>(
|
||||
ItemQueries.GET_LOGO_ID,
|
||||
[itemId]
|
||||
);
|
||||
const logoId = logoResult.length > 0 ? logoResult[0].LogoId : null;
|
||||
|
||||
// Hard delete all related entities
|
||||
this.hardDeleteByForeignKey('FieldValues', 'ItemId', itemId);
|
||||
this.hardDeleteByForeignKey('FieldHistories', 'ItemId', itemId);
|
||||
this.hardDeleteByForeignKey('Passkeys', 'ItemId', itemId);
|
||||
this.hardDeleteByForeignKey('TotpCodes', 'ItemId', itemId);
|
||||
this.hardDeleteByForeignKey('Attachments', 'ItemId', itemId);
|
||||
this.hardDeleteByForeignKey('ItemTags', 'ItemId', itemId);
|
||||
|
||||
// Convert item to tombstone
|
||||
const result = this.client.executeUpdate(ItemQueries.TOMBSTONE_ITEM, [
|
||||
currentDateTime,
|
||||
itemId
|
||||
]);
|
||||
|
||||
// Clean up orphaned logo
|
||||
if (logoId) {
|
||||
this.logoRepository.cleanupOrphanedLogo(logoId);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all items in "Recently Deleted".
|
||||
* @returns Array of trashed Item objects with DeletedAt
|
||||
*/
|
||||
public getRecentlyDeleted(): ItemWithDeletedAt[] {
|
||||
let itemRows: (ItemRow & { DeletedAt: string })[];
|
||||
try {
|
||||
// Need a modified query that includes DeletedAt in the SELECT
|
||||
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`;
|
||||
|
||||
itemRows = this.client.executeQuery(query);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('no such table')) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (itemRows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const itemIds = itemRows.map(i => i.Id);
|
||||
|
||||
// Get all field values
|
||||
const fieldRows = this.client.executeQuery<FieldRow>(
|
||||
ItemQueries.getFieldValuesForItems(itemIds.length),
|
||||
itemIds
|
||||
);
|
||||
const fieldsByItem = FieldMapper.processFieldRows(fieldRows);
|
||||
|
||||
return itemRows.map(row => ItemMapper.mapDeletedItemRow(row, fieldsByItem.get(row.Id) || []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of items in "Recently Deleted".
|
||||
* @returns Number of trashed items
|
||||
*/
|
||||
public getRecentlyDeletedCount(): number {
|
||||
try {
|
||||
const result = this.client.executeQuery<{ count: number }>(ItemQueries.COUNT_RECENTLY_DELETED);
|
||||
return result[0]?.count || 0;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('no such table')) {
|
||||
return 0;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field history for a specific field.
|
||||
* @param itemId - The ID of the item
|
||||
* @param fieldKey - The field key to get history for
|
||||
* @returns Array of field history records
|
||||
*/
|
||||
public getFieldHistory(itemId: string, fieldKey: string): FieldHistory[] {
|
||||
const results = this.client.executeQuery<{
|
||||
Id: string;
|
||||
ItemId: string;
|
||||
FieldKey: string;
|
||||
ValueSnapshot: string;
|
||||
ChangedAt: string;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
}>(FieldHistoryQueries.GET_FOR_FIELD, [itemId, fieldKey, MAX_FIELD_HISTORY_RECORDS]);
|
||||
|
||||
return results.map(row => ({
|
||||
Id: row.Id,
|
||||
ItemId: row.ItemId,
|
||||
FieldKey: row.FieldKey,
|
||||
ValueSnapshot: row.ValueSnapshot,
|
||||
ChangedAt: row.ChangedAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt
|
||||
}));
|
||||
}
|
||||
|
||||
// ===== Private Helper Methods =====
|
||||
|
||||
/**
|
||||
* Resolve the logo ID for an item (create new or reuse existing).
|
||||
*/
|
||||
private resolveLogoId(item: Item, currentDateTime: string): string | null {
|
||||
const urlField = item.Fields?.find(f => f.FieldKey === 'login.url');
|
||||
const urlValue = urlField?.Value;
|
||||
const urlString = Array.isArray(urlValue) ? urlValue[0] : urlValue;
|
||||
const source = this.logoRepository.extractSourceFromUrl(urlString);
|
||||
|
||||
if (item.Logo) {
|
||||
const logoData = this.logoRepository.convertLogoToUint8Array(item.Logo);
|
||||
if (logoData) {
|
||||
return this.logoRepository.getOrCreate(source, logoData, currentDateTime);
|
||||
}
|
||||
} else if (source !== 'unknown') {
|
||||
return this.logoRepository.getIdForSource(source);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert field values for a new item.
|
||||
*/
|
||||
private insertFieldValues(
|
||||
itemId: string,
|
||||
fields: ItemField[],
|
||||
itemType: string,
|
||||
currentDateTime: string
|
||||
): void {
|
||||
for (const field of fields) {
|
||||
// Skip empty fields
|
||||
if (!field.Value || (typeof field.Value === 'string' && field.Value.trim() === '')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isCustomField = field.FieldKey.startsWith('custom_');
|
||||
let fieldDefinitionId = null;
|
||||
|
||||
// For custom fields, create or get FieldDefinition
|
||||
if (isCustomField) {
|
||||
fieldDefinitionId = this.ensureFieldDefinition(field, itemType, currentDateTime);
|
||||
}
|
||||
|
||||
// Handle multi-value fields
|
||||
const values = Array.isArray(field.Value) ? field.Value : [field.Value];
|
||||
|
||||
for (const value of values) {
|
||||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.client.executeUpdate(FieldValueQueries.INSERT, [
|
||||
this.generateId(),
|
||||
itemId,
|
||||
fieldDefinitionId,
|
||||
isCustomField ? null : field.FieldKey,
|
||||
value,
|
||||
field.DisplayOrder ?? 0,
|
||||
currentDateTime,
|
||||
currentDateTime,
|
||||
0
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a field definition exists for a custom field.
|
||||
*/
|
||||
private ensureFieldDefinition(
|
||||
field: ItemField,
|
||||
itemType: string,
|
||||
currentDateTime: string
|
||||
): string {
|
||||
const existingDef = this.client.executeQuery<{ Id: string }>(
|
||||
FieldDefinitionQueries.EXISTS,
|
||||
[field.FieldKey]
|
||||
);
|
||||
|
||||
if (existingDef.length === 0) {
|
||||
this.client.executeUpdate(FieldDefinitionQueries.INSERT, [
|
||||
field.FieldKey,
|
||||
field.FieldType,
|
||||
field.Label,
|
||||
0, // IsMultiValue
|
||||
field.IsHidden ? 1 : 0,
|
||||
0, // EnableHistory
|
||||
field.DisplayOrder ?? 0,
|
||||
itemType,
|
||||
currentDateTime,
|
||||
currentDateTime,
|
||||
0
|
||||
]);
|
||||
}
|
||||
|
||||
return field.FieldKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update field values for an existing item.
|
||||
*/
|
||||
private updateFieldValues(item: Item, currentDateTime: string): void {
|
||||
// Get existing FieldValues
|
||||
const existingFieldValues = this.client.executeQuery<{
|
||||
Id: string;
|
||||
FieldKey: string | null;
|
||||
FieldDefinitionId: string | null;
|
||||
Value: string;
|
||||
}>(FieldValueQueries.GET_EXISTING_FOR_ITEM, [item.Id]);
|
||||
|
||||
// Build a map of existing FieldValues by key:index
|
||||
const existingByKey = new Map<string, { Id: string; Value: string }>();
|
||||
const fieldValueCounts = new Map<string, number>();
|
||||
|
||||
for (const fv of existingFieldValues) {
|
||||
const key = fv.FieldKey || fv.FieldDefinitionId || '';
|
||||
const count = fieldValueCounts.get(key) || 0;
|
||||
existingByKey.set(`${key}:${count}`, { Id: fv.Id, Value: fv.Value });
|
||||
fieldValueCounts.set(key, count + 1);
|
||||
}
|
||||
|
||||
const processedIds = new Set<string>();
|
||||
|
||||
// Update existing or insert new FieldValues
|
||||
if (item.Fields && item.Fields.length > 0) {
|
||||
for (const field of item.Fields) {
|
||||
if (!field.Value || (typeof field.Value === 'string' && field.Value.trim() === '')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isCustomField = field.FieldKey.startsWith('custom_');
|
||||
let fieldDefinitionId = null;
|
||||
|
||||
if (isCustomField) {
|
||||
fieldDefinitionId = this.ensureOrUpdateFieldDefinition(field, item.ItemType, currentDateTime);
|
||||
}
|
||||
|
||||
const values = Array.isArray(field.Value) ? field.Value : [field.Value];
|
||||
const effectiveKey = field.FieldKey;
|
||||
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const value = values[i];
|
||||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lookupKey = `${effectiveKey}:${i}`;
|
||||
const existing = existingByKey.get(lookupKey);
|
||||
|
||||
if (existing) {
|
||||
processedIds.add(existing.Id);
|
||||
if (existing.Value !== value) {
|
||||
this.client.executeUpdate(FieldValueQueries.UPDATE, [
|
||||
value,
|
||||
field.DisplayOrder ?? 0,
|
||||
currentDateTime,
|
||||
existing.Id
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
this.client.executeUpdate(FieldValueQueries.INSERT, [
|
||||
this.generateId(),
|
||||
item.Id,
|
||||
fieldDefinitionId,
|
||||
isCustomField ? null : field.FieldKey,
|
||||
value,
|
||||
field.DisplayOrder ?? 0,
|
||||
currentDateTime,
|
||||
currentDateTime,
|
||||
0
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Soft-delete any FieldValues that were not processed
|
||||
for (const fv of existingFieldValues) {
|
||||
if (!processedIds.has(fv.Id)) {
|
||||
this.client.executeUpdate(FieldValueQueries.SOFT_DELETE, [currentDateTime, fv.Id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a field definition exists and is up-to-date.
|
||||
*/
|
||||
private ensureOrUpdateFieldDefinition(
|
||||
field: ItemField,
|
||||
itemType: string,
|
||||
currentDateTime: string
|
||||
): string {
|
||||
const existingDef = this.client.executeQuery<{ Id: string }>(
|
||||
FieldDefinitionQueries.EXISTS_ACTIVE,
|
||||
[field.FieldKey]
|
||||
);
|
||||
|
||||
if (existingDef.length === 0) {
|
||||
this.client.executeUpdate(FieldDefinitionQueries.INSERT, [
|
||||
field.FieldKey,
|
||||
field.FieldType,
|
||||
field.Label,
|
||||
0,
|
||||
field.IsHidden ? 1 : 0,
|
||||
0,
|
||||
field.DisplayOrder ?? 0,
|
||||
itemType,
|
||||
currentDateTime,
|
||||
currentDateTime,
|
||||
0
|
||||
]);
|
||||
} else {
|
||||
this.client.executeUpdate(FieldDefinitionQueries.UPDATE, [
|
||||
field.Label,
|
||||
field.FieldType,
|
||||
field.IsHidden ? 1 : 0,
|
||||
field.DisplayOrder ?? 0,
|
||||
currentDateTime,
|
||||
field.FieldKey
|
||||
]);
|
||||
}
|
||||
|
||||
return field.FieldKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track field history for fields with EnableHistory=true.
|
||||
*/
|
||||
private async trackFieldHistory(
|
||||
itemId: string,
|
||||
newFields: ItemField[],
|
||||
currentDateTime: string
|
||||
): Promise<void> {
|
||||
const existingFields = this.client.executeQuery<{ FieldKey: string; Value: string }>(
|
||||
FieldValueQueries.GET_FOR_HISTORY,
|
||||
[itemId]
|
||||
);
|
||||
|
||||
// Create a map of existing values by FieldKey
|
||||
const existingValuesMap: { [key: string]: string[] } = {};
|
||||
for (const field of existingFields) {
|
||||
if (!existingValuesMap[field.FieldKey]) {
|
||||
existingValuesMap[field.FieldKey] = [];
|
||||
}
|
||||
existingValuesMap[field.FieldKey].push(field.Value);
|
||||
}
|
||||
|
||||
for (const newField of newFields) {
|
||||
// Skip custom fields
|
||||
if (newField.FieldKey.startsWith('custom_')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const systemField = getSystemField(newField.FieldKey);
|
||||
if (!systemField || !systemField.EnableHistory) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldValues = existingValuesMap[newField.FieldKey] || [];
|
||||
const newValues = Array.isArray(newField.Value) ? newField.Value : [newField.Value];
|
||||
|
||||
const valuesChanged = oldValues.length !== newValues.length ||
|
||||
!oldValues.every((val, idx) => val === newValues[idx]);
|
||||
|
||||
if (valuesChanged && oldValues.length > 0) {
|
||||
const historyId = this.generateId();
|
||||
const valueSnapshot = JSON.stringify(oldValues);
|
||||
|
||||
this.client.executeUpdate(FieldHistoryQueries.INSERT, [
|
||||
historyId,
|
||||
itemId,
|
||||
null,
|
||||
newField.FieldKey,
|
||||
valueSnapshot,
|
||||
currentDateTime,
|
||||
currentDateTime,
|
||||
currentDateTime,
|
||||
0
|
||||
]);
|
||||
|
||||
await this.pruneFieldHistory(itemId, newField.FieldKey, currentDateTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune old field history records.
|
||||
*/
|
||||
private async pruneFieldHistory(
|
||||
itemId: string,
|
||||
fieldKey: string,
|
||||
currentDateTime: string
|
||||
): Promise<void> {
|
||||
const matchingHistory = this.client.executeQuery<{ Id: string; ChangedAt: string }>(
|
||||
FieldHistoryQueries.GET_FOR_PRUNING,
|
||||
[itemId, fieldKey]
|
||||
);
|
||||
|
||||
if (matchingHistory.length > MAX_FIELD_HISTORY_RECORDS) {
|
||||
const recordsToDelete = matchingHistory.slice(MAX_FIELD_HISTORY_RECORDS);
|
||||
const idsToDelete = recordsToDelete.map(r => r.Id);
|
||||
|
||||
if (idsToDelete.length > 0) {
|
||||
this.client.executeUpdate(
|
||||
FieldHistoryQueries.softDeleteOld(idsToDelete.length),
|
||||
[currentDateTime, ...idsToDelete]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert TOTP codes for a new item.
|
||||
*/
|
||||
private insertTotpCodes(itemId: string, totpCodes: TotpCode[], currentDateTime: string): void {
|
||||
for (const totpCode of totpCodes) {
|
||||
this.client.executeUpdate(
|
||||
`INSERT INTO TotpCodes (Id, Name, SecretKey, ItemId, CreatedAt, UpdatedAt, IsDeleted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
totpCode.Id || this.generateId(),
|
||||
totpCode.Name,
|
||||
totpCode.SecretKey,
|
||||
itemId,
|
||||
currentDateTime,
|
||||
currentDateTime,
|
||||
0
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle TOTP code updates.
|
||||
*/
|
||||
private handleTotpCodes(
|
||||
itemId: string,
|
||||
totpCodes: TotpCode[],
|
||||
originalIds: string[],
|
||||
currentDateTime: string
|
||||
): void {
|
||||
for (const totpCode of totpCodes) {
|
||||
const wasOriginal = originalIds.includes(totpCode.Id);
|
||||
|
||||
if (totpCode.IsDeleted) {
|
||||
if (wasOriginal) {
|
||||
this.client.executeUpdate(
|
||||
`UPDATE TotpCodes SET IsDeleted = 1, UpdatedAt = ? WHERE Id = ?`,
|
||||
[currentDateTime, totpCode.Id]
|
||||
);
|
||||
}
|
||||
} else if (wasOriginal) {
|
||||
this.client.executeUpdate(
|
||||
`UPDATE TotpCodes SET Name = ?, SecretKey = ?, UpdatedAt = ? WHERE Id = ?`,
|
||||
[totpCode.Name, totpCode.SecretKey, currentDateTime, totpCode.Id]
|
||||
);
|
||||
} else {
|
||||
this.client.executeUpdate(
|
||||
`INSERT INTO TotpCodes (Id, Name, SecretKey, ItemId, CreatedAt, UpdatedAt, IsDeleted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
totpCode.Id || this.generateId(),
|
||||
totpCode.Name,
|
||||
totpCode.SecretKey,
|
||||
itemId,
|
||||
currentDateTime,
|
||||
currentDateTime,
|
||||
0
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert attachments for a new item.
|
||||
*/
|
||||
private insertAttachments(itemId: string, attachments: Attachment[], currentDateTime: string): void {
|
||||
for (const attachment of attachments) {
|
||||
const blobData = attachment.Blob instanceof Uint8Array
|
||||
? attachment.Blob
|
||||
: new Uint8Array(attachment.Blob);
|
||||
|
||||
this.client.executeUpdate(
|
||||
`INSERT INTO Attachments (Id, Filename, Blob, ItemId, CreatedAt, UpdatedAt, IsDeleted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
attachment.Id || this.generateId(),
|
||||
attachment.Filename,
|
||||
blobData,
|
||||
itemId,
|
||||
currentDateTime,
|
||||
currentDateTime,
|
||||
0
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle attachment updates.
|
||||
*/
|
||||
private handleAttachments(
|
||||
itemId: string,
|
||||
attachments: Attachment[],
|
||||
originalIds: string[],
|
||||
currentDateTime: string
|
||||
): void {
|
||||
for (const attachment of attachments) {
|
||||
const wasOriginal = originalIds.includes(attachment.Id);
|
||||
|
||||
if (attachment.IsDeleted) {
|
||||
if (wasOriginal) {
|
||||
this.client.executeUpdate(
|
||||
`UPDATE Attachments SET IsDeleted = 1, UpdatedAt = ? WHERE Id = ?`,
|
||||
[currentDateTime, attachment.Id]
|
||||
);
|
||||
}
|
||||
} else if (!wasOriginal) {
|
||||
const blobData = attachment.Blob instanceof Uint8Array
|
||||
? attachment.Blob
|
||||
: new Uint8Array(attachment.Blob);
|
||||
|
||||
this.client.executeUpdate(
|
||||
`INSERT INTO Attachments (Id, Filename, Blob, ItemId, CreatedAt, UpdatedAt, IsDeleted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
attachment.Id || this.generateId(),
|
||||
attachment.Filename,
|
||||
blobData,
|
||||
itemId,
|
||||
currentDateTime,
|
||||
currentDateTime,
|
||||
0
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { BaseRepository } from '../BaseRepository';
|
||||
|
||||
/**
|
||||
* SQL query constants for Logo operations.
|
||||
*/
|
||||
const LogoQueries = {
|
||||
/**
|
||||
* Check if logo exists for source.
|
||||
*/
|
||||
GET_ID_FOR_SOURCE: `
|
||||
SELECT Id FROM Logos
|
||||
WHERE Source = ? AND IsDeleted = 0
|
||||
LIMIT 1`,
|
||||
|
||||
/**
|
||||
* Insert new logo.
|
||||
*/
|
||||
INSERT: `
|
||||
INSERT INTO Logos (Id, Source, FileData, CreatedAt, UpdatedAt, IsDeleted)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
|
||||
/**
|
||||
* Count items using a logo.
|
||||
*/
|
||||
COUNT_USAGE: `
|
||||
SELECT COUNT(*) as count FROM Items
|
||||
WHERE LogoId = ? AND IsDeleted = 0`,
|
||||
|
||||
/**
|
||||
* Hard delete logo.
|
||||
*/
|
||||
HARD_DELETE: `
|
||||
DELETE FROM Logos WHERE Id = ?`
|
||||
};
|
||||
|
||||
/**
|
||||
* Repository for Logo management operations.
|
||||
*/
|
||||
export class LogoRepository extends BaseRepository {
|
||||
/**
|
||||
* Check if a logo exists for the given source domain.
|
||||
* @param source The normalized source domain (e.g., 'github.com')
|
||||
* @returns True if a logo exists for this source
|
||||
*/
|
||||
public hasLogoForSource(source: string): boolean {
|
||||
const existingLogos = this.client.executeQuery<{ Id: string }>(
|
||||
LogoQueries.GET_ID_FOR_SOURCE,
|
||||
[source]
|
||||
);
|
||||
return existingLogos.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the logo ID for a given source domain if it exists.
|
||||
* @param source The normalized source domain (e.g., 'github.com')
|
||||
* @returns The logo ID if found, null otherwise
|
||||
*/
|
||||
public getIdForSource(source: string): string | null {
|
||||
const existingLogos = this.client.executeQuery<{ Id: string }>(
|
||||
LogoQueries.GET_ID_FOR_SOURCE,
|
||||
[source]
|
||||
);
|
||||
return existingLogos.length > 0 ? existingLogos[0].Id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a logo ID for the given source domain.
|
||||
* If a logo for this source already exists, returns its ID.
|
||||
* Otherwise, creates a new logo entry and returns its ID.
|
||||
* @param source The normalized source domain (e.g., 'github.com')
|
||||
* @param logoData The logo image data as Uint8Array
|
||||
* @param currentDateTime The current date/time string for timestamps
|
||||
* @returns The logo ID (existing or newly created)
|
||||
*/
|
||||
public getOrCreate(source: string, logoData: Uint8Array, currentDateTime: string): string {
|
||||
// Check if a logo for this source already exists
|
||||
const existingId = this.getIdForSource(source);
|
||||
if (existingId) {
|
||||
return existingId;
|
||||
}
|
||||
|
||||
// Create new logo entry
|
||||
const logoId = this.generateId();
|
||||
this.client.executeUpdate(LogoQueries.INSERT, [
|
||||
logoId,
|
||||
source,
|
||||
logoData,
|
||||
currentDateTime,
|
||||
currentDateTime,
|
||||
0
|
||||
]);
|
||||
|
||||
return logoId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up orphaned logo if no items reference it.
|
||||
* @param logoId - The ID of the logo to potentially clean up
|
||||
*/
|
||||
public cleanupOrphanedLogo(logoId: string): void {
|
||||
const usageResult = this.client.executeQuery<{ count: number }>(
|
||||
LogoQueries.COUNT_USAGE,
|
||||
[logoId]
|
||||
);
|
||||
const usageCount = usageResult.length > 0 ? usageResult[0].count : 0;
|
||||
|
||||
if (usageCount === 0) {
|
||||
this.client.executeUpdate(LogoQueries.HARD_DELETE, [logoId]);
|
||||
console.debug(`[LogoRepository] Deleted orphaned logo: ${logoId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and normalize source domain from a URL string.
|
||||
* Uses lowercase and removes www. prefix for case-insensitive matching.
|
||||
* @param urlString The URL to extract the domain from
|
||||
* @returns The normalized source domain (e.g., 'github.com'), or 'unknown' if extraction fails
|
||||
*/
|
||||
public extractSourceFromUrl(urlString: string | undefined | null): string {
|
||||
if (!urlString) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(urlString.startsWith('http') ? urlString : `https://${urlString}`);
|
||||
// Normalize hostname: lowercase and remove www. prefix
|
||||
return url.hostname.toLowerCase().replace(/^www\./, '');
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert logo data from various formats to Uint8Array.
|
||||
* @param logo The logo data in various possible formats
|
||||
* @returns Uint8Array of logo data, or null if conversion fails
|
||||
*/
|
||||
public convertLogoToUint8Array(logo: unknown): Uint8Array | null {
|
||||
if (!logo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle object-like array conversion (from JSON deserialization)
|
||||
if (typeof logo === 'object' && !ArrayBuffer.isView(logo) && !Array.isArray(logo)) {
|
||||
const values = Object.values(logo as Record<string, number>);
|
||||
return new Uint8Array(values);
|
||||
}
|
||||
// Handle existing array types
|
||||
if (Array.isArray(logo) || logo instanceof ArrayBuffer || logo instanceof Uint8Array) {
|
||||
return new Uint8Array(logo as ArrayLike<number>);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to convert logo to Uint8Array:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import type { Passkey } from '@/utils/dist/core/models/vault';
|
||||
import { FieldKey } from '@/utils/dist/core/models/vault';
|
||||
|
||||
import { BaseRepository } from '../BaseRepository';
|
||||
import { PasskeyMapper, type PasskeyRow, type PasskeyWithItemRow, type PasskeyWithItem } from '../mappers/PasskeyMapper';
|
||||
|
||||
/**
|
||||
* SQL query constants for Passkey operations.
|
||||
*/
|
||||
const PasskeyQueries = {
|
||||
/**
|
||||
* Base SELECT for passkeys with item information.
|
||||
*/
|
||||
BASE_SELECT_WITH_ITEM: `
|
||||
SELECT
|
||||
p.Id,
|
||||
p.ItemId,
|
||||
p.RpId,
|
||||
p.UserHandle,
|
||||
p.PublicKey,
|
||||
p.PrivateKey,
|
||||
p.DisplayName,
|
||||
p.PrfKey,
|
||||
p.AdditionalData,
|
||||
p.CreatedAt,
|
||||
p.UpdatedAt,
|
||||
p.IsDeleted,
|
||||
i.Name as ServiceName,
|
||||
(SELECT fv.Value FROM FieldValues fv WHERE fv.ItemId = i.Id AND fv.FieldKey = '${FieldKey.LoginUsername}' AND fv.IsDeleted = 0 LIMIT 1) as Username
|
||||
FROM Passkeys p
|
||||
INNER JOIN Items i ON p.ItemId = i.Id`,
|
||||
|
||||
/**
|
||||
* Base SELECT for passkeys without item information.
|
||||
*/
|
||||
BASE_SELECT: `
|
||||
SELECT
|
||||
p.Id,
|
||||
p.ItemId,
|
||||
p.RpId,
|
||||
p.UserHandle,
|
||||
p.PublicKey,
|
||||
p.PrivateKey,
|
||||
p.DisplayName,
|
||||
p.PrfKey,
|
||||
p.AdditionalData,
|
||||
p.CreatedAt,
|
||||
p.UpdatedAt,
|
||||
p.IsDeleted
|
||||
FROM Passkeys p`,
|
||||
|
||||
/**
|
||||
* Get passkeys by relying party ID.
|
||||
*/
|
||||
GET_BY_RP_ID: `
|
||||
SELECT
|
||||
p.Id,
|
||||
p.ItemId,
|
||||
p.RpId,
|
||||
p.UserHandle,
|
||||
p.PublicKey,
|
||||
p.PrivateKey,
|
||||
p.DisplayName,
|
||||
p.PrfKey,
|
||||
p.AdditionalData,
|
||||
p.CreatedAt,
|
||||
p.UpdatedAt,
|
||||
p.IsDeleted,
|
||||
i.Name as ServiceName,
|
||||
(SELECT fv.Value FROM FieldValues fv WHERE fv.ItemId = i.Id AND fv.FieldKey = '${FieldKey.LoginUsername}' AND fv.IsDeleted = 0 LIMIT 1) as Username
|
||||
FROM Passkeys p
|
||||
INNER JOIN Items i ON p.ItemId = i.Id
|
||||
WHERE p.RpId = ? AND p.IsDeleted = 0
|
||||
AND i.IsDeleted = 0 AND i.DeletedAt IS NULL
|
||||
ORDER BY p.CreatedAt DESC`,
|
||||
|
||||
/**
|
||||
* Get passkey by ID with item information.
|
||||
*/
|
||||
GET_BY_ID_WITH_ITEM: `
|
||||
SELECT
|
||||
p.Id,
|
||||
p.ItemId,
|
||||
p.RpId,
|
||||
p.UserHandle,
|
||||
p.PublicKey,
|
||||
p.PrivateKey,
|
||||
p.DisplayName,
|
||||
p.PrfKey,
|
||||
p.AdditionalData,
|
||||
p.CreatedAt,
|
||||
p.UpdatedAt,
|
||||
p.IsDeleted,
|
||||
i.Name as ServiceName,
|
||||
(SELECT fv.Value FROM FieldValues fv WHERE fv.ItemId = i.Id AND fv.FieldKey = '${FieldKey.LoginUsername}' AND fv.IsDeleted = 0 LIMIT 1) as Username
|
||||
FROM Passkeys p
|
||||
INNER JOIN Items i ON p.ItemId = i.Id
|
||||
WHERE p.Id = ? AND p.IsDeleted = 0
|
||||
AND i.IsDeleted = 0 AND i.DeletedAt IS NULL`,
|
||||
|
||||
/**
|
||||
* Get passkeys by item ID.
|
||||
*/
|
||||
GET_BY_ITEM_ID: `
|
||||
SELECT
|
||||
p.Id,
|
||||
p.ItemId,
|
||||
p.RpId,
|
||||
p.UserHandle,
|
||||
p.PublicKey,
|
||||
p.PrivateKey,
|
||||
p.DisplayName,
|
||||
p.PrfKey,
|
||||
p.AdditionalData,
|
||||
p.CreatedAt,
|
||||
p.UpdatedAt,
|
||||
p.IsDeleted
|
||||
FROM Passkeys p
|
||||
WHERE p.ItemId = ? AND p.IsDeleted = 0
|
||||
ORDER BY p.CreatedAt DESC`,
|
||||
|
||||
/**
|
||||
* Insert a new passkey.
|
||||
*/
|
||||
INSERT: `
|
||||
INSERT INTO Passkeys (
|
||||
Id, ItemId, RpId, UserHandle, PublicKey, PrivateKey,
|
||||
PrfKey, DisplayName, AdditionalData, CreatedAt, UpdatedAt, IsDeleted
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
|
||||
/**
|
||||
* Update passkey display name.
|
||||
*/
|
||||
UPDATE_DISPLAY_NAME: `
|
||||
UPDATE Passkeys
|
||||
SET DisplayName = ?,
|
||||
UpdatedAt = ?
|
||||
WHERE Id = ?`,
|
||||
|
||||
/**
|
||||
* Soft delete passkey by ID.
|
||||
*/
|
||||
SOFT_DELETE: `
|
||||
UPDATE Passkeys
|
||||
SET IsDeleted = 1,
|
||||
UpdatedAt = ?
|
||||
WHERE Id = ?`,
|
||||
|
||||
/**
|
||||
* Soft delete passkeys by item ID.
|
||||
*/
|
||||
SOFT_DELETE_BY_ITEM: `
|
||||
UPDATE Passkeys
|
||||
SET IsDeleted = 1,
|
||||
UpdatedAt = ?
|
||||
WHERE ItemId = ?`
|
||||
};
|
||||
|
||||
/**
|
||||
* Repository for Passkey CRUD operations.
|
||||
*/
|
||||
export class PasskeyRepository extends BaseRepository {
|
||||
/**
|
||||
* Get all passkeys for a specific relying party (rpId).
|
||||
* @param rpId - The relying party identifier (domain)
|
||||
* @returns Array of passkey objects with credential info
|
||||
*/
|
||||
public getByRpId(rpId: string): PasskeyWithItem[] {
|
||||
const results = this.client.executeQuery<PasskeyWithItemRow>(
|
||||
PasskeyQueries.GET_BY_RP_ID,
|
||||
[rpId]
|
||||
);
|
||||
return PasskeyMapper.mapRowsWithItem(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a passkey by its ID.
|
||||
* @param passkeyId - The passkey ID
|
||||
* @returns The passkey object or null if not found
|
||||
*/
|
||||
public getById(passkeyId: string): PasskeyWithItem | null {
|
||||
const results = this.client.executeQuery<PasskeyWithItemRow>(
|
||||
PasskeyQueries.GET_BY_ID_WITH_ITEM,
|
||||
[passkeyId]
|
||||
);
|
||||
|
||||
if (results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PasskeyMapper.mapRowWithItem(results[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all passkeys for a specific item.
|
||||
* @param itemId - The item ID
|
||||
* @returns Array of passkey objects
|
||||
*/
|
||||
public getByItemId(itemId: string): Passkey[] {
|
||||
const results = this.client.executeQuery<PasskeyRow>(
|
||||
PasskeyQueries.GET_BY_ITEM_ID,
|
||||
[itemId]
|
||||
);
|
||||
return PasskeyMapper.mapRows(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new passkey linked to an item.
|
||||
* @param passkey - The passkey object to create
|
||||
*/
|
||||
public async create(passkey: Omit<Passkey, 'CreatedAt' | 'UpdatedAt' | 'IsDeleted'>): Promise<void> {
|
||||
return this.withTransaction(async () => {
|
||||
const currentDateTime = this.now();
|
||||
|
||||
// Convert PrfKey to Uint8Array if it's a number array
|
||||
let prfKeyData: Uint8Array | null = null;
|
||||
if (passkey.PrfKey) {
|
||||
prfKeyData = passkey.PrfKey instanceof Uint8Array
|
||||
? passkey.PrfKey
|
||||
: new Uint8Array(passkey.PrfKey);
|
||||
}
|
||||
|
||||
// Convert UserHandle to Uint8Array if it's a number array
|
||||
let userHandleData: Uint8Array | null = null;
|
||||
if (passkey.UserHandle) {
|
||||
userHandleData = passkey.UserHandle instanceof Uint8Array
|
||||
? passkey.UserHandle
|
||||
: new Uint8Array(passkey.UserHandle);
|
||||
}
|
||||
|
||||
this.client.executeUpdate(PasskeyQueries.INSERT, [
|
||||
passkey.Id,
|
||||
passkey.ItemId,
|
||||
passkey.RpId,
|
||||
userHandleData,
|
||||
passkey.PublicKey,
|
||||
passkey.PrivateKey,
|
||||
prfKeyData,
|
||||
passkey.DisplayName,
|
||||
passkey.AdditionalData ?? null,
|
||||
currentDateTime,
|
||||
currentDateTime,
|
||||
0
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a passkey by its ID (soft delete).
|
||||
* @param passkeyId - The ID of the passkey to delete
|
||||
* @returns The number of rows updated
|
||||
*/
|
||||
public async deleteById(passkeyId: string): Promise<number> {
|
||||
return this.withTransaction(async () => {
|
||||
const currentDateTime = this.now();
|
||||
return this.client.executeUpdate(PasskeyQueries.SOFT_DELETE, [
|
||||
currentDateTime,
|
||||
passkeyId
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all passkeys for a specific item (soft delete).
|
||||
* @param itemId - The ID of the item
|
||||
* @returns The number of rows updated
|
||||
*/
|
||||
public async deleteByItemId(itemId: string): Promise<number> {
|
||||
return this.withTransaction(async () => {
|
||||
const currentDateTime = this.now();
|
||||
return this.client.executeUpdate(PasskeyQueries.SOFT_DELETE_BY_ITEM, [
|
||||
currentDateTime,
|
||||
itemId
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a passkey's display name.
|
||||
* @param passkeyId - The ID of the passkey to update
|
||||
* @param displayName - The new display name
|
||||
* @returns The number of rows updated
|
||||
*/
|
||||
public async updateDisplayName(passkeyId: string, displayName: string): Promise<number> {
|
||||
return this.withTransaction(async () => {
|
||||
const currentDateTime = this.now();
|
||||
return this.client.executeUpdate(PasskeyQueries.UPDATE_DISPLAY_NAME, [
|
||||
displayName,
|
||||
currentDateTime,
|
||||
passkeyId
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { EncryptionKey, PasswordSettings, TotpCode, Attachment } from '@/utils/dist/core/models/vault';
|
||||
|
||||
import { BaseRepository } from '../BaseRepository';
|
||||
|
||||
/**
|
||||
* SQL query constants for Settings and related operations.
|
||||
*/
|
||||
const SettingsQueries = {
|
||||
/**
|
||||
* Get setting by key.
|
||||
*/
|
||||
GET_SETTING: `
|
||||
SELECT s.Value
|
||||
FROM Settings s
|
||||
WHERE s.Key = ?`,
|
||||
|
||||
/**
|
||||
* Get all encryption keys.
|
||||
*/
|
||||
GET_ENCRYPTION_KEYS: `
|
||||
SELECT
|
||||
x.PublicKey,
|
||||
x.PrivateKey,
|
||||
x.IsPrimary
|
||||
FROM EncryptionKeys x`,
|
||||
|
||||
/**
|
||||
* Get TOTP codes for an item.
|
||||
*/
|
||||
GET_TOTP_FOR_ITEM: `
|
||||
SELECT
|
||||
Id,
|
||||
Name,
|
||||
SecretKey,
|
||||
ItemId
|
||||
FROM TotpCodes
|
||||
WHERE ItemId = ? AND IsDeleted = 0`,
|
||||
|
||||
/**
|
||||
* Get attachments for an item.
|
||||
*/
|
||||
GET_ATTACHMENTS_FOR_ITEM: `
|
||||
SELECT
|
||||
Id,
|
||||
Filename,
|
||||
Blob,
|
||||
ItemId,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
IsDeleted
|
||||
FROM Attachments
|
||||
WHERE ItemId = ? AND IsDeleted = 0`
|
||||
};
|
||||
|
||||
/**
|
||||
* Repository for Settings and auxiliary data operations.
|
||||
*/
|
||||
export class SettingsRepository extends BaseRepository {
|
||||
/**
|
||||
* Get setting from database for a given key.
|
||||
* Returns default value (empty string by default) if setting is not found.
|
||||
* @param key - The setting key
|
||||
* @param defaultValue - Default value if setting not found
|
||||
* @returns The setting value
|
||||
*/
|
||||
public getSetting(key: string, defaultValue: string = ''): string {
|
||||
const results = this.client.executeQuery<{ Value: string }>(
|
||||
SettingsQueries.GET_SETTING,
|
||||
[key]
|
||||
);
|
||||
return results.length > 0 ? results[0].Value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default identity language from the database.
|
||||
* @returns The stored override value if set, otherwise empty string
|
||||
*/
|
||||
public getDefaultIdentityLanguage(): string {
|
||||
return this.getSetting('DefaultIdentityLanguage');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default identity gender preference from the database.
|
||||
* @returns The gender preference or 'random' if not set
|
||||
*/
|
||||
public getDefaultIdentityGender(): string {
|
||||
return this.getSetting('DefaultIdentityGender', 'random');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default identity age range from the database.
|
||||
* @returns The age range preference or 'random' if not set
|
||||
*/
|
||||
public getDefaultIdentityAgeRange(): string {
|
||||
return this.getSetting('DefaultIdentityAgeRange', 'random');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the password settings from the database.
|
||||
* @returns Password settings object
|
||||
*/
|
||||
public getPasswordSettings(): PasswordSettings {
|
||||
const settingsJson = this.getSetting('PasswordGenerationSettings');
|
||||
|
||||
const defaultSettings: PasswordSettings = {
|
||||
Length: 18,
|
||||
UseLowercase: true,
|
||||
UseUppercase: true,
|
||||
UseNumbers: true,
|
||||
UseSpecialChars: true,
|
||||
UseNonAmbiguousChars: false
|
||||
};
|
||||
|
||||
try {
|
||||
if (settingsJson) {
|
||||
return { ...defaultSettings, ...JSON.parse(settingsJson) };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse password settings:', error);
|
||||
}
|
||||
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all encryption keys.
|
||||
* @returns Array of encryption keys
|
||||
*/
|
||||
public getAllEncryptionKeys(): EncryptionKey[] {
|
||||
return this.client.executeQuery<EncryptionKey>(SettingsQueries.GET_ENCRYPTION_KEYS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get TOTP codes for an item.
|
||||
* @param itemId - The ID of the item to get TOTP codes for
|
||||
* @returns Array of TotpCode objects
|
||||
*/
|
||||
public getTotpCodesForItem(itemId: string): TotpCode[] {
|
||||
try {
|
||||
if (!this.tableExists('TotpCodes')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.client.executeQuery<TotpCode>(SettingsQueries.GET_TOTP_FOR_ITEM, [itemId]);
|
||||
} catch (error) {
|
||||
console.error('Error getting TOTP codes for item:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attachments for an item.
|
||||
* @param itemId - The ID of the item
|
||||
* @returns Array of attachments for the item
|
||||
*/
|
||||
public getAttachmentsForItem(itemId: string): Attachment[] {
|
||||
try {
|
||||
if (!this.tableExists('Attachments')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.client.executeQuery<Attachment>(
|
||||
SettingsQueries.GET_ATTACHMENTS_FOR_ITEM,
|
||||
[itemId]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error getting attachments for item:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default email domain for new aliases.
|
||||
* @returns The default email domain or empty string if not set
|
||||
*/
|
||||
public getDefaultEmailDomain(): string {
|
||||
return this.getSetting('DefaultEmailDomain');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective identity language, falling back to browser language.
|
||||
* @returns The effective language code
|
||||
*/
|
||||
public getEffectiveIdentityLanguage(): string {
|
||||
const storedLanguage = this.getDefaultIdentityLanguage();
|
||||
if (storedLanguage) {
|
||||
return storedLanguage;
|
||||
}
|
||||
// Fall back to browser language (first two characters)
|
||||
return navigator.language.substring(0, 2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user