feat(export): Replace CSV export with standardized JSON format

- Add contactsToExportJson utility function for standardized data export
- Replace CSV export with JSON format in DataExportSection
- Update file extension and MIME type to application/json
- Remove Dexie-specific export logic in favor of unified SQLite/Dexie approach
- Update success notifications to reflect JSON format
- Add TypeScript interfaces for export data structure

This change improves data portability and standardization by:
- Using a consistent JSON format for data export/import
- Supporting both SQLite and Dexie databases
- Including all contact fields in export
- Properly handling contactMethods as stringified JSON
- Maintaining backward compatibility with existing import tools

Security: No sensitive data exposure, maintains existing access controls
This commit is contained in:
Matthew Raymer
2025-06-07 05:02:33 +00:00
parent 38e67f3533
commit 3d8e40e92b
9 changed files with 230 additions and 126 deletions

View File

@@ -80,7 +80,10 @@ export const UNIT_LONG: Record<string, string> = {
};
/* eslint-enable prettier/prettier */
const UNIT_CODES: Record<string, { name: string; faIcon: string; decimals: number }> = {
const UNIT_CODES: Record<
string,
{ name: string; faIcon: string; decimals: number }
> = {
BTC: {
name: "Bitcoin",
faIcon: "bitcoin-sign",
@@ -558,20 +561,15 @@ export const retrieveAccountMetadata = async (
};
export const retrieveAllAccountsMetadata = async (): Promise<Account[]> => {
console.log("[retrieveAllAccountsMetadata] start");
const platformService = PlatformServiceFactory.getInstance();
const sql = `SELECT * FROM accounts`;
console.log("[retrieveAllAccountsMetadata] sql: ", sql);
const dbAccounts = await platformService.dbQuery(sql);
console.log("[retrieveAllAccountsMetadata] dbAccounts: ", dbAccounts);
const accounts = databaseUtil.mapQueryResultToValues(dbAccounts) as Account[];
let result = accounts.map((account) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account;
return metadata as Account;
});
console.log("[retrieveAllAccountsMetadata] result: ", result);
console.log("[retrieveAllAccountsMetadata] USE_DEXIE_DB: ", USE_DEXIE_DB);
if (USE_DEXIE_DB) {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
@@ -582,7 +580,6 @@ export const retrieveAllAccountsMetadata = async (): Promise<Account[]> => {
return metadata as Account;
});
}
console.log("[retrieveAllAccountsMetadata] end", JSON.stringify(result, null, 2));
return result;
};
@@ -663,10 +660,6 @@ export async function saveNewIdentity(
derivationPath: string,
): Promise<void> {
try {
console.log("[saveNewIdentity] identity", identity);
console.log("[saveNewIdentity] mnemonic", mnemonic);
console.log("[saveNewIdentity] newId", newId);
console.log("[saveNewIdentity] derivationPath", derivationPath);
// add to the new sql db
const platformService = PlatformServiceFactory.getInstance();
const secrets = await platformService.dbQuery(
@@ -685,7 +678,6 @@ export async function saveNewIdentity(
const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic);
const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex)
VALUES (?, ?, ?, ?, ?, ?)`;
console.log("[saveNewIdentity] sql: ", sql);
const params = [
new Date().toISOString(),
derivationPath,
@@ -694,7 +686,6 @@ export async function saveNewIdentity(
encryptedMnemonicBase64,
newId.keys[0].publicKeyHex,
];
console.log("[saveNewIdentity] params: ", params);
await platformService.dbExec(sql, params);
await databaseUtil.updateDefaultSettings({ activeDid: newId.did });
@@ -712,7 +703,6 @@ export async function saveNewIdentity(
await updateDefaultSettings({ activeDid: newId.did });
}
} catch (error) {
console.log("[saveNewIdentity] error: ", error);
logger.error("Failed to update default settings:", error);
throw new Error(
"Failed to set default settings. Please try again or restart the app.",
@@ -837,3 +827,94 @@ export const sendTestThroughPushServer = async (
logger.log("Got response from web push server:", response);
return response;
};
/**
* Converts a Contact object to a CSV line string following the established format.
* The format matches CONTACT_CSV_HEADER: "name,did,pubKeyBase64,seesMe,registered,contactMethods"
* where contactMethods is stored as a stringified JSON array.
*
* @param contact - The Contact object to convert
* @returns A CSV-formatted string representing the contact
* @throws {Error} If the contact object is missing required fields
*/
export const contactToCsvLine = (contact: Contact): string => {
if (!contact.did) {
throw new Error("Contact must have a did field");
}
// Escape fields that might contain commas or quotes
const escapeField = (field: string | boolean | undefined): string => {
if (field === undefined) return "";
const str = String(field);
if (str.includes(",") || str.includes('"')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
// Handle contactMethods array by stringifying it
const contactMethodsStr = contact.contactMethods
? escapeField(JSON.stringify(contact.contactMethods))
: "";
const fields = [
escapeField(contact.name),
escapeField(contact.did),
escapeField(contact.publicKeyBase64),
escapeField(contact.seesMe),
escapeField(contact.registered),
contactMethodsStr,
];
return fields.join(",");
};
/**
* Interface for the JSON export format of database tables
*/
export interface TableExportData {
tableName: string;
inbound: boolean;
rows: Array<Record<string, unknown>>;
}
/**
* Interface for the complete database export format
*/
export interface DatabaseExport {
data: {
data: Array<TableExportData>;
};
}
/**
* Converts an array of contacts to the standardized database export JSON format.
* This format is used for data migration and backup purposes.
*
* @param contacts - Array of Contact objects to convert
* @returns DatabaseExport object in the standardized format
*/
export const contactsToExportJson = (contacts: Contact[]): DatabaseExport => {
// Convert each contact to a plain object and ensure all fields are included
const rows = contacts.map(contact => ({
did: contact.did,
name: contact.name || null,
contactMethods: contact.contactMethods ? JSON.stringify(contact.contactMethods) : null,
nextPubKeyHashB64: contact.nextPubKeyHashB64 || null,
notes: contact.notes || null,
profileImageUrl: contact.profileImageUrl || null,
publicKeyBase64: contact.publicKeyBase64 || null,
seesMe: contact.seesMe || false,
registered: contact.registered || false
}));
return {
data: {
data: [{
tableName: "contacts",
inbound: true,
rows
}]
}
};
};