// many of these are also found in endorser-mobile utility.ts import axios, { AxiosResponse } from "axios"; import { Buffer } from "buffer"; import * as R from "ramda"; import { useClipboard } from "@vueuse/core"; import { DEFAULT_PUSH_SERVER, NotificationIface, USE_DEXIE_DB, } from "../constants/app"; import { accountsDBPromise, retrieveSettingsForActiveAccount, updateAccountSettings, updateDefaultSettings, } from "../db/index"; import { Account, AccountEncrypted } from "../db/tables/accounts"; import { Contact } from "../db/tables/contacts"; import * as databaseUtil from "../db/databaseUtil"; import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings"; import { arrayBufferToBase64, base64ToArrayBuffer, deriveAddress, generateSeed, newIdentifier, simpleDecrypt, simpleEncrypt, } from "../libs/crypto"; import * as serverUtil from "../libs/endorserServer"; import { containsHiddenDid } from "../libs/endorserServer"; import { GenericCredWrapper, GenericVerifiableCredential, } from "../interfaces/common"; import { GiveSummaryRecord } from "../interfaces/records"; import { OfferVerifiableCredential } from "../interfaces/claims"; import { KeyMeta } from "../interfaces/common"; import { createPeerDid } from "../libs/crypto/vc/didPeer"; import { registerCredential } from "../libs/crypto/vc/passkeyDidPeer"; import { logger } from "../utils/logger"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; export interface GiverReceiverInputInfo { did?: string; name?: string; } export enum OnboardPage { Home = "HOME", Discover = "DISCOVER", Create = "CREATE", Contact = "CONTACT", Account = "ACCOUNT", } export const PRIVACY_MESSAGE = "The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow."; export const SHARED_PHOTO_BASE64_KEY = "shared-photo-base64"; /* eslint-disable prettier/prettier */ export const UNIT_SHORT: Record = { "BTC": "BTC", "BX": "BX", "ETH": "ETH", "HUR": "Hours", "USD": "US $", }; /* eslint-enable prettier/prettier */ /* eslint-disable prettier/prettier */ export const UNIT_LONG: Record = { "BTC": "Bitcoin", "BX": "Buxbe", "ETH": "Ethereum", "HUR": "hours", "USD": "dollars", }; /* eslint-enable prettier/prettier */ const UNIT_CODES: Record< string, { name: string; faIcon: string; decimals: number } > = { BTC: { name: "Bitcoin", faIcon: "bitcoin-sign", decimals: 4, }, HUR: { name: "hours", faIcon: "clock", decimals: 0, }, USD: { name: "US Dollars", faIcon: "dollar", decimals: 2, }, }; export function iconForUnitCode(unitCode: string) { return UNIT_CODES[unitCode]?.faIcon || "question"; } export function formattedAmount(amount: number, unitCode: string) { const unit = UNIT_CODES[unitCode]; const amountStr = amount.toFixed(unit?.decimals ?? 4); const unitName = unit?.name || "?"; return amountStr + " " + unitName; } // from https://stackoverflow.com/a/175787/845494 // ... though it appears even this isn't precisely right so keep doing "|| 0" or something in sensitive places // export function isNumeric(str: string): boolean { // This ignore commentary is because typescript complains when you pass a string to isNaN. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return !isNaN(str) && !isNaN(parseFloat(str)); } export function numberOrZero(str: string): number { return isNumeric(str) ? +str : 0; } /** * from https://tools.ietf.org/html/rfc3986#section-3 * also useful is https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Definition **/ export const isGlobalUri = (uri: string) => { return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/)); }; export const isGiveClaimType = (claimType?: string) => { return claimType === "GiveAction"; }; export const isGiveAction = ( veriClaim: GenericCredWrapper, ) => { return isGiveClaimType(veriClaim.claimType); }; export const shortDid = (did: string) => { if (did.startsWith("did:peer:")) { return ( did.substring(0, "did:peer:".length + 2) + "..." + did.substring("did:peer:".length + 18, "did:peer:".length + 25) + "..." ); } else if (did.startsWith("did:ethr:")) { return did.substring(0, "did:ethr:".length + 9) + "..."; } else { return did.substring(0, did.indexOf(":", 4) + 7) + "..."; } }; export const nameForDid = ( activeDid: string, contacts: Array, did: string, ): string => { if (did === activeDid) { return "you"; } const contact = R.find((con) => con.did == did, contacts); return nameForContact(contact); }; export const nameForContact = ( contact?: Contact, capitalize?: boolean, ): string => { return ( (contact?.name as string) || (capitalize ? "This" : "this") + " unnamed user" ); }; export const doCopyTwoSecRedo = (text: string, fn: () => void) => { fn(); useClipboard() .copy(text) .then(() => setTimeout(fn, 2000)); }; export interface ConfirmerData { confirmerIdList: string[]; confsVisibleToIdList: string[]; numConfsNotVisible: number; } // // This is meant to be a second argument to JSON.stringify to avoid circular references. // // Usage: JSON.stringify(error, getCircularReplacer()) // // Beware: we've seen this return "undefined" when there is actually a message, eg: DatabaseClosedError: Error DEXIE ENCRYPT ADDON: Encryption key has changed // function getCircularReplacer() { // const seen = new WeakSet(); // // eslint-disable-next-line @typescript-eslint/no-explicit-any // return (obj: any, key: string, value: any): any => { // if (typeof value === "object" && value !== null) { // if (seen.has(value)) { // return "[circular ref]"; // } // seen.add(value); // } // return value; // }; // } /** * @return only confirmers, excluding the issuer and hidden DIDs */ export async function retrieveConfirmerIdList( apiServer: string, claimId: string, claimIssuerId: string, userDid: string, ): Promise { const confirmUrl = apiServer + "/api/report/issuersWhoClaimedOrConfirmed?claimId=" + encodeURIComponent(serverUtil.stripEndorserPrefix(claimId)); const confirmHeaders = await serverUtil.getHeaders(userDid); const response = await axios.get(confirmUrl, { headers: confirmHeaders, }); if (response.status === 200) { const resultList1 = response.data.result || []; //const publicUrls = resultList.publicUrls || []; delete resultList1.publicUrls; // exclude hidden DIDs const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1); // exclude the issuer const resultList3 = R.reject( (did: string) => did === claimIssuerId, resultList2, ); const confirmerIdList = resultList3; let numConfsNotVisible = resultList1.length - resultList2.length; if (resultList3.length === resultList2.length) { // the issuer was not in the "visible" list so they must be hidden // so subtract them from the non-visible confirmers count numConfsNotVisible = numConfsNotVisible - 1; } const confsVisibleToIdList = response.data.result.resultVisibleToDids || []; const result: ConfirmerData = { confirmerIdList, confsVisibleToIdList, numConfsNotVisible, }; return result; } else { logger.error( "Bad response status of", response.status, "for confirmers:", response, ); return undefined; } } /** * @returns true if the user can confirm the claim * @param veriClaim is expected to have fields: claim, claimType, and issuer */ export function isGiveRecordTheUserCanConfirm( isRegistered: boolean, veriClaim: GenericCredWrapper, activeDid: string, confirmerIdList: string[] = [], ): boolean { return ( isRegistered && isGiveAction(veriClaim) && !confirmerIdList.includes(activeDid) && veriClaim.issuer !== activeDid && !containsHiddenDid(veriClaim.claim) ); } export function notifyWhyCannotConfirm( notifyFun: (notification: NotificationIface, timeout: number) => void, isRegistered: boolean, claimType: string | undefined, giveDetails: GiveSummaryRecord | undefined, activeDid: string, confirmerIdList: string[] = [], ) { if (!isRegistered) { notifyFun( { group: "alert", type: "info", title: "Not Registered", text: "Someone needs to register you before you can confirm.", }, 3000, ); } else if (!isGiveClaimType(claimType)) { notifyFun( { group: "alert", type: "info", title: "Not A Give", text: "This is not a giving action to confirm.", }, 3000, ); } else if (confirmerIdList.includes(activeDid)) { notifyFun( { group: "alert", type: "info", title: "Already Confirmed", text: "You already confirmed this claim.", }, 3000, ); } else if (giveDetails?.issuerDid == activeDid) { notifyFun( { group: "alert", type: "info", title: "Cannot Confirm", text: "You cannot confirm this because you issued this claim.", }, 3000, ); } else if (serverUtil.containsHiddenDid(giveDetails?.fullClaim)) { notifyFun( { group: "alert", type: "info", title: "Cannot Confirm", text: "You cannot confirm this because some people are hidden.", }, 3000, ); } else { notifyFun( { group: "alert", type: "info", title: "Cannot Confirm", text: "You cannot confirm this claim. There are no other details -- we can help more if you contact us and send us screenshots.", }, 3000, ); } } export async function blobToBase64(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); // potential problem if it returns an ArrayBuffer? reader.onerror = reject; reader.readAsDataURL(blob); }); } export function base64ToBlob(base64DataUrl: string, sliceSize = 512) { // Extract the content type and the Base64 data const [metadata, base64] = base64DataUrl.split(","); const contentTypeMatch = metadata.match(/data:(.*?);base64/); const contentType = contentTypeMatch ? contentTypeMatch[1] : ""; const byteCharacters = atob(base64); const byteArrays = []; for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { const slice = byteCharacters.slice(offset, offset + sliceSize); const byteNumbers = new Array(slice.length); for (let i = 0; i < slice.length; i++) { byteNumbers[i] = slice.charCodeAt(i); } const byteArray = new Uint8Array(byteNumbers); byteArrays.push(byteArray); } return new Blob(byteArrays, { type: contentType }); } /** * @returns the DID of the person who offered, or undefined if hidden * @param veriClaim is expected to have fields: claim and issuer */ export function offerGiverDid( veriClaim: GenericCredWrapper, ): string | undefined { let giver; const claim = veriClaim.claim as OfferVerifiableCredential; const offeredBy: { identifier?: string } | undefined = claim.offeredBy || claim.credentialSubject?.offeredBy; const offeredById = offeredBy?.identifier; if (offeredById && !serverUtil.isHiddenDid(offeredById)) { giver = offeredById; } else if (veriClaim.issuer && !serverUtil.isHiddenDid(veriClaim.issuer)) { giver = veriClaim.issuer; } return giver; } /** * @returns true if the user can fulfill the offer * @param veriClaim is expected to have fields: claim, claimType, and issuer */ export const canFulfillOffer = ( veriClaim: GenericCredWrapper, ) => { return veriClaim.claimType === "Offer" && !!offerGiverDid(veriClaim); }; // return object with paths and arrays of DIDs for any keys ending in "VisibleToDid" export function findAllVisibleToDids( // eslint-disable-next-line @typescript-eslint/no-explicit-any input: any, humanReadable = false, ): Record> { if (Array.isArray(input)) { const result: Record> = {}; for (let i = 0; i < input.length; i++) { const inside = findAllVisibleToDids(input[i], humanReadable); for (const key in inside) { const pathKey = humanReadable ? "#" + (i + 1) + " " + key : "[" + i + "]" + key; result[pathKey] = inside[key]; } } return result; } else if (input instanceof Object) { // regular map (non-array) object const result: Record> = {}; for (const key in input) { if (key.endsWith("VisibleToDids")) { const newKey = key.slice(0, -"VisibleToDids".length); const pathKey = humanReadable ? newKey : "." + newKey; result[pathKey] = input[key]; } else { const inside = findAllVisibleToDids(input[key], humanReadable); for (const insideKey in inside) { const pathKey = humanReadable ? key + "'s " + insideKey : "." + key + insideKey; result[pathKey] = inside[insideKey]; } } } return result; } else { return {}; } } /** * Test findAllVisibleToDids * pkgx +deno.land sh deno import * as R from 'ramda'; //import { findAllVisibleToDids } from './src/libs/util'; // doesn't work because other dependencies fail so gotta copy-and-paste function console.log(R.equals(findAllVisibleToDids(null), {})); console.log(R.equals(findAllVisibleToDids(9), {})); console.log(R.equals(findAllVisibleToDids([]), {})); console.log(R.equals(findAllVisibleToDids({}), {})); console.log(R.equals(findAllVisibleToDids({ issuer: "abc" }), {})); console.log(R.equals(findAllVisibleToDids({ issuerVisibleToDids: ["abc"] }), { ".issuer": ["abc"] })); console.log(R.equals(findAllVisibleToDids([{ issuerVisibleToDids: ["abc"] }]), { "[0].issuer": ["abc"] })); console.log(R.equals(findAllVisibleToDids(["xyz", { fluff: { issuerVisibleToDids: ["abc"] } }]), { "[1].fluff.issuer": ["abc"] })); console.log(R.equals(findAllVisibleToDids(["xyz", { fluff: { issuerVisibleToDids: ["abc"] }, stuff: [ { did: "HIDDEN", agentDidVisibleToDids: ["def", "ghi"] } ] }]), { "[1].fluff.issuer": ["abc"], "[1].stuff[0].agentDid": ["def", "ghi"] })); * **/ export interface AccountKeyInfo extends Omit, Omit { derivationPath?: string; // Make it optional to match Account type } export const retrieveAccountCount = async (): Promise => { let result = 0; const platformService = PlatformServiceFactory.getInstance(); const dbResult = await platformService.dbQuery( `SELECT COUNT(*) FROM accounts`, ); if (dbResult?.values?.[0]?.[0]) { result = dbResult.values[0][0] as number; } if (USE_DEXIE_DB) { // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; result = await accountsDB.accounts.count(); } return result; }; export const retrieveAccountDids = async (): Promise => { const platformService = PlatformServiceFactory.getInstance(); const dbAccounts = await platformService.dbQuery(`SELECT did FROM accounts`); let allDids = databaseUtil .mapQueryResultToValues(dbAccounts) ?.map((row) => row[0] as string) || []; if (USE_DEXIE_DB) { // this is the old way // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; const allAccounts = await accountsDB.accounts.toArray(); allDids = allAccounts.map((acc) => acc.did); } return allDids; }; // This is provided and recommended when the full key is not necessary so that // future work could separate this info from the sensitive key material. export const retrieveAccountMetadata = async ( activeDid: string, ): Promise => { let result: AccountKeyInfo | undefined = undefined; const platformService = PlatformServiceFactory.getInstance(); const dbAccount = await platformService.dbQuery( `SELECT * FROM accounts WHERE did = ?`, [activeDid], ); const account = databaseUtil.mapQueryResultToValues(dbAccount)[0] as Account; if (account) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { identity, mnemonic, ...metadata } = account; result = metadata; } else { result = undefined; } if (USE_DEXIE_DB) { // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; const account = (await accountsDB.accounts .where("did") .equals(activeDid) .first()) as Account; if (account) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { identity, mnemonic, ...metadata } = account; result = metadata; } else { result = undefined; } } return result; }; export const retrieveAllAccountsMetadata = async (): Promise => { const platformService = PlatformServiceFactory.getInstance(); const sql = `SELECT * FROM accounts`; const dbAccounts = await platformService.dbQuery(sql); 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; }); if (USE_DEXIE_DB) { // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; const array = await accountsDB.accounts.toArray(); result = array.map((account) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { identity, mnemonic, ...metadata } = account; return metadata as Account; }); } return result; }; export const retrieveFullyDecryptedAccount = async ( activeDid: string, ): Promise => { let result: AccountKeyInfo | undefined = undefined; const platformService = PlatformServiceFactory.getInstance(); const dbSecrets = await platformService.dbQuery( `SELECT secretBase64 from secret`, ); if ( !dbSecrets || dbSecrets.values.length === 0 || dbSecrets.values[0].length === 0 ) { throw new Error( "No secret found. We recommend you clear your data and start over.", ); } const secretBase64 = dbSecrets.values[0][0] as string; const secret = base64ToArrayBuffer(secretBase64); const dbAccount = await platformService.dbQuery( `SELECT * FROM accounts WHERE did = ?`, [activeDid], ); if ( !dbAccount || dbAccount.values.length === 0 || dbAccount.values[0].length === 0 ) { throw new Error("Account not found."); } const fullAccountData = databaseUtil.mapQueryResultToValues( dbAccount, )[0] as AccountEncrypted; const identityEncr = base64ToArrayBuffer(fullAccountData.identityEncrBase64); const mnemonicEncr = base64ToArrayBuffer(fullAccountData.mnemonicEncrBase64); fullAccountData.identity = await simpleDecrypt(identityEncr, secret); fullAccountData.mnemonic = await simpleDecrypt(mnemonicEncr, secret); result = fullAccountData; if (USE_DEXIE_DB) { // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; const account = (await accountsDB.accounts .where("did") .equals(activeDid) .first()) as Account; result = account; } return result; }; // let's try and eliminate this export const retrieveAllFullyDecryptedAccounts = async (): Promise< Array > => { const platformService = PlatformServiceFactory.getInstance(); const queryResult = await platformService.dbQuery("SELECT * FROM accounts"); let allAccounts = databaseUtil.mapQueryResultToValues( queryResult, ) as unknown as AccountEncrypted[]; if (USE_DEXIE_DB) { const accountsDB = await accountsDBPromise; allAccounts = (await accountsDB.accounts.toArray()) as AccountEncrypted[]; } return allAccounts; }; /** * Saves a new identity to both SQL and Dexie databases */ export async function saveNewIdentity( identity: string, mnemonic: string, newId: { did: string; keys: Array<{ publicKeyHex: string }> }, derivationPath: string, ): Promise { try { // add to the new sql db const platformService = PlatformServiceFactory.getInstance(); const secrets = await platformService.dbQuery( `SELECT secretBase64 FROM secret`, ); if (!secrets?.values?.length || !secrets.values[0]?.length) { throw new Error( "No initial encryption supported. We recommend you clear your data and start over.", ); } const secretBase64 = secrets.values[0][0] as string; const secret = base64ToArrayBuffer(secretBase64); const encryptedIdentity = await simpleEncrypt(identity, secret); const encryptedMnemonic = await simpleEncrypt(mnemonic, secret); const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity); const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic); const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex) VALUES (?, ?, ?, ?, ?, ?)`; const params = [ new Date().toISOString(), derivationPath, newId.did, encryptedIdentityBase64, encryptedMnemonicBase64, newId.keys[0].publicKeyHex, ]; await platformService.dbExec(sql, params); await databaseUtil.updateDefaultSettings({ activeDid: newId.did }); if (USE_DEXIE_DB) { // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; await accountsDB.accounts.add({ dateCreated: new Date().toISOString(), derivationPath: derivationPath, did: newId.did, identity: identity, mnemonic: mnemonic, publicKeyHex: newId.keys[0].publicKeyHex, }); await updateDefaultSettings({ activeDid: newId.did }); } } catch (error) { logger.error("Failed to update default settings:", error); throw new Error( "Failed to set default settings. Please try again or restart the app.", ); } } /** * Generates a new identity, saves it to the database, and sets it as the active identity. * @return {Promise} with the DID of the new identity */ export const generateSaveAndActivateIdentity = async (): Promise => { const mnemonic = generateSeed(); // address is 0x... ETH address, without "did:eth:" const [address, privateHex, publicHex, derivationPath] = deriveAddress(mnemonic); const newId = newIdentifier(address, publicHex, privateHex, derivationPath); const identity = JSON.stringify(newId); await saveNewIdentity(identity, mnemonic, newId, derivationPath); await databaseUtil.updateAccountSettings(newId.did, { isRegistered: false }); if (USE_DEXIE_DB) { await updateAccountSettings(newId.did, { isRegistered: false }); } return newId.did; }; export const registerAndSavePasskey = async ( keyName: string, ): Promise => { const cred = await registerCredential(keyName); const publicKeyBytes = cred.publicKeyBytes; const did = createPeerDid(publicKeyBytes as Uint8Array); const passkeyCredIdHex = cred.credIdHex as string; const account = { dateCreated: new Date().toISOString(), did, passkeyCredIdHex, publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"), }; const insertStatement = databaseUtil.generateInsertStatement( account, "accounts", ); await PlatformServiceFactory.getInstance().dbExec( insertStatement.sql, insertStatement.params, ); if (USE_DEXIE_DB) { // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; await accountsDB.accounts.add(account); } return account; }; export const registerSaveAndActivatePasskey = async ( keyName: string, ): Promise => { const account = await registerAndSavePasskey(keyName); await databaseUtil.updateDefaultSettings({ activeDid: account.did }); await databaseUtil.updateAccountSettings(account.did, { isRegistered: false, }); if (USE_DEXIE_DB) { await updateDefaultSettings({ activeDid: account.did }); await updateAccountSettings(account.did, { isRegistered: false }); } return account; }; export const getPasskeyExpirationSeconds = async (): Promise => { let settings = await databaseUtil.retrieveSettingsForActiveAccount(); if (USE_DEXIE_DB) { settings = await retrieveSettingsForActiveAccount(); } return ( (settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) * 60 ); }; // These are shared with the service worker and should be a constant. Look for the same name in additional-scripts.js export const DAILY_CHECK_TITLE = "DAILY_CHECK"; // This is a special value that tells the service worker to send a direct notification to the device, skipping filters. export const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION"; export const sendTestThroughPushServer = async ( subscriptionJSON: PushSubscriptionJSON, skipFilter: boolean, ): Promise => { let settings = await databaseUtil.retrieveSettingsForActiveAccount(); if (USE_DEXIE_DB) { settings = await retrieveSettingsForActiveAccount(); } let pushUrl: string = DEFAULT_PUSH_SERVER as string; if (settings?.webPushServer) { pushUrl = settings.webPushServer; } const newPayload = { ...subscriptionJSON, // ... overridden with the following // eslint-disable-next-line prettier/prettier message: `Test, where you will see this message ${ skipFilter ? "un" : "" }filtered.`, title: skipFilter ? DIRECT_PUSH_TITLE : "Your Web Push", }; logger.log("Sending a test web push message:", newPayload); const payloadStr = JSON.stringify(newPayload); const response = await axios.post( pushUrl + "/web-push/send-test", payloadStr, { headers: { "Content-Type": "application/json", }, }, ); 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; rows: Array>; } /** * Interface for the complete database export format */ export interface DatabaseExport { data: { data: Array; }; } /** * 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", rows, }, ], }, }; };