diff --git a/src/components/DataExportSection.vue b/src/components/DataExportSection.vue index 16dd992b..64e67673 100644 --- a/src/components/DataExportSection.vue +++ b/src/components/DataExportSection.vue @@ -105,11 +105,9 @@ import { Component, Prop, Vue } from "vue-facing-decorator"; import { Router } from "vue-router"; import * as R from "ramda"; -import { AppString, NotificationIface } from "../constants/app"; -import { Contact } from "../db/tables/contacts"; +import { NotificationIface } from "../constants/app"; import { logger } from "../utils/logger"; -import { contactsToExportJson } from "../libs/util"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView"; @@ -222,26 +220,15 @@ export default class DataExportSection extends Vue { return "list-disc list-outside ml-4"; } - /** - * Computed property for the export file name - * Includes today's date for easy identification of backup files - */ - private get fileName(): string { - const today = new Date(); - const dateString = today.toISOString().split("T")[0]; // YYYY-MM-DD format - return `${AppString.APP_NAME_NO_SPACES}-backup-contacts-${dateString}.json`; - } - /** * Exports the database to a JSON file + * Exports contacts and contact labels tables * Uses the platform service to handle platform-specific export logic * Shows success/error notifications to user * * @throws {Error} If export fails */ public async exportDatabase(): Promise { - // Note that similar code is in ContactsView.vue exportContactData() - if (this.isExporting) { return; // Prevent multiple simultaneous exports } @@ -249,49 +236,11 @@ export default class DataExportSection extends Vue { try { this.isExporting = true; - // Fetch contacts from database using mixin's cached method - const allContacts = await this.$contacts(); - - // Convert contacts to export format - const processedContacts: Contact[] = allContacts.map((contact) => { - // first remove the contactMethods field, mostly to cast to a clear type (that will end up with JSON objects) - const exContact: Contact = R.omit(["contactMethods"], contact); - // now add contactMethods as a true array of ContactMethod objects - // $contacts() returns normalized contacts where contactMethods is already an array, - // but we handle both array and string cases for robustness - if (contact.contactMethods) { - if (Array.isArray(contact.contactMethods)) { - // Already an array, use it directly - exContact.contactMethods = contact.contactMethods; - } else { - // Check if it's a string that needs parsing (shouldn't happen with normalized contacts, but handle for robustness) - const contactMethodsValue = contact.contactMethods as unknown; - if ( - typeof contactMethodsValue === "string" && - contactMethodsValue.trim() !== "" - ) { - // String that needs parsing - exContact.contactMethods = JSON.parse(contactMethodsValue); - } else { - // Invalid data, use empty array - exContact.contactMethods = []; - } - } - } else { - // No contactMethods, use empty array - exContact.contactMethods = []; - } - return exContact; - }); - - const exportData = contactsToExportJson(processedContacts); - const jsonStr = JSON.stringify(exportData, null, 2); - - // Use platform service to handle export (no platform-specific logic here!) - await this.platformService.writeAndShareFile(this.fileName, jsonStr); + // Prepare export data using shared utility function + await this.$saveContactExport(); this.notify.success( - "Contact export completed successfully. Check your downloads or share dialog.", + "Contact export completed successfully. Check downloads or the share dialog.", ); } catch (error) { logger.error("Export Error:", error); diff --git a/src/db/tables/contacts.ts b/src/db/tables/contacts.ts index fe81cbe4..4da7b559 100644 --- a/src/db/tables/contacts.ts +++ b/src/db/tables/contacts.ts @@ -45,6 +45,8 @@ export type ContactMaybeWithJsonStrings = Omit & { contactMethods?: string | Array; }; +export type ContactWithLabels = Contact & { labels?: Array }; + export const ContactSchema = { contacts: "&did, name", // no need to key by other things }; diff --git a/src/libs/util.ts b/src/libs/util.ts index d62b23e3..0a69499e 100644 --- a/src/libs/util.ts +++ b/src/libs/util.ts @@ -941,43 +941,6 @@ export const csvLineToContact = (lineRaw: string): Contact => { return newContact; }; -/** - * 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 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 => { - return { - data: { - data: [ - { - tableName: "contacts", - rows: contacts, - }, - ], - }, - }; -}; - /** * Imports an account from a mnemonic phrase * @param mnemonic - The seed phrase to import from diff --git a/src/utils/PlatformServiceMixin.ts b/src/utils/PlatformServiceMixin.ts index 516f7dd1..c773e92d 100644 --- a/src/utils/PlatformServiceMixin.ts +++ b/src/utils/PlatformServiceMixin.ts @@ -39,6 +39,8 @@ * @updated 2025-06-25 - Added high-level entity operations for code reduction */ +import * as R from "ramda"; + import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import type { PlatformService, @@ -49,7 +51,11 @@ import { type SettingsWithJsonStrings, } from "@/db/tables/settings"; import { logger } from "@/utils/logger"; -import { Contact, ContactMaybeWithJsonStrings } from "@/db/tables/contacts"; +import { + Contact, + ContactMaybeWithJsonStrings, + ContactWithLabels, +} from "@/db/tables/contacts"; import { Account } from "@/db/tables/accounts"; import { Temp } from "@/db/tables/temp"; import { @@ -61,6 +67,7 @@ import { generateInsertStatement, generateUpdateStatement, } from "@/utils/sqlHelpers"; +import { AppString } from "../constants/app"; // ================================================= // TYPESCRIPT INTERFACES @@ -86,6 +93,23 @@ interface VueComponentWithMixin { platformService(): PlatformService; } +/** + * 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; + }; +} + // /** // * Global cache store for mixin instances // * Uses WeakMap to avoid memory leaks when components are destroyed @@ -1501,7 +1525,7 @@ export const PlatformServiceMixin = { * @param did Contact DID * @returns Promise Array of labels */ - async $getContactLabels(did: string): Promise { + async $getContactLabelsForDid(did: string): Promise { try { const results = (await this.$dbQuery( "SELECT label FROM contact_labels WHERE did = ? ORDER BY label", @@ -1952,6 +1976,133 @@ export const PlatformServiceMixin = { ); }, + /** + * Converts an array of contacts to the 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 + */ + $contactsToExportJson(contacts: ContactWithLabels[]): DatabaseExport { + return { + data: { + data: [ + { + tableName: "contacts", + rows: contacts, + }, + ], + }, + }; + }, + + /** + * Prepares contact and label data for export to a JSON file. + * Handles normalization of contact data and generates a timestamped filename. + * + * @param appName - Application name for filename (defaults to "TimeSafari") + * @returns Object containing the JSON string and filename + * + * @example + * ```typescript + * const contacts = await $contacts(); + * const labelsResult = await $dbQuery("SELECT did, label FROM contact_labels"); + * const labels = mapQueryResultToValues(labelsResult); + * const { jsonString, fileName } = saveContactExport(contacts, labels); + * await platformService.writeAndShareFile(fileName, jsonString); + * ``` + */ + async $saveContactExport(): Promise { + const contacts = await this.$contacts(); + + // Fetch all contact labels from database + const labelsResult = await this.$dbQuery( + "SELECT did, label FROM contact_labels ORDER BY did, label", + ); + // create a map of did to labels + const contactToLabelsMap = new Map(); + // iterate over the labelsResult and accumulate the labels for each did + labelsResult?.values?.forEach((contactLabel: [string, string]) => { + const did = contactLabel[0]; + const label = contactLabel[1]; + if (!contactToLabelsMap.has(did)) { + contactToLabelsMap.set(did, []); + } + contactToLabelsMap.get(did)?.push(label); + }); + + // Process contacts to normalize contactMethods field + // Handle both array format (from normalized contacts) and string format (legacy/database) + const processedContacts: ContactWithLabels[] = contacts.map((contact) => { + // Remove contactMethods field temporarily to get a clean type + const exContact: ContactWithLabels = R.omit( + ["contactMethods"], + contact, + ); + + // Add contactMethods as a proper array of ContactMethod objects + if (contact.contactMethods) { + if (Array.isArray(contact.contactMethods)) { + // Already an array, use it directly + exContact.contactMethods = contact.contactMethods; + } else { + // Check if it's a string that needs parsing + const contactMethodsValue = contact.contactMethods as unknown; + if ( + typeof contactMethodsValue === "string" && + contactMethodsValue.trim() !== "" + ) { + try { + // String that needs parsing + exContact.contactMethods = JSON.parse(contactMethodsValue); + } catch (error) { + // Invalid JSON, use empty array + logger.warn( + `Invalid contactMethods JSON for contact ${contact.did}:`, + error, + ); + exContact.contactMethods = []; + } + } else { + // Invalid data, use empty array + exContact.contactMethods = []; + } + } + } else { + // No contactMethods, use empty array + exContact.contactMethods = []; + } + + // add the labels to the contact + exContact.labels = contactToLabelsMap.get(contact.did) || []; + + return exContact; + }); + + // Build export data with contacts + const exportData = this.$contactsToExportJson(processedContacts); + + // Generate JSON string + const jsonString = JSON.stringify(exportData, null, 2); + + // Generate filename with current date + const today = new Date(); + const dateString = today.toISOString().split("T")[0]; // YYYY-MM-DD format + const appName = AppString.APP_NAME_NO_SPACES; + const fileName = `${appName}-backup-contacts-${dateString}.json`; + + // Use platform service to handle export + await ( + this as unknown as IPlatformServiceMixin + ).platformService.writeAndShareFile(fileName, jsonString); + + return fileName; + }, + + // ================================================= + // DEBUGGING + // ================================================= + /** * Debug method to verify settings for a specific DID * Useful for troubleshooting settings propagation issues @@ -2086,7 +2237,7 @@ export interface IPlatformServiceMixin { $getContact(did: string): Promise; $deleteContact(did: string): Promise; $contactCount(): Promise; - $getContactLabels(did: string): Promise; + $getContactLabelsForDid(did: string): Promise; $getContactIdsWithAllLabels(labels: string[]): Promise; $addContactLabel(did: string, label: string): Promise; $deleteContactLabel(did: string, label: string): Promise; @@ -2146,6 +2297,9 @@ export interface IPlatformServiceMixin { values: unknown[][], ): Array>; + // Contact export methods + $saveContactExport(): Promise; + // Debug methods $debugDidSettings(did: string): Promise; $debugMergedSettings(did: string): Promise; @@ -2233,7 +2387,7 @@ declare module "@vue/runtime-core" { $getContact(did: string): Promise; $deleteContact(did: string): Promise; $contactCount(): Promise; - $getContactLabels(did: string): Promise; + $getContactLabelsForDid(did: string): Promise; $getContactIdsWithAllLabels(labels: string[]): Promise; $addContactLabel(did: string, label: string): Promise; $deleteContactLabel(did: string, label: string): Promise; @@ -2293,6 +2447,9 @@ declare module "@vue/runtime-core" { values: unknown[][], ): Array>; + // Contact export methods + $saveContactExport(): Promise; + // Debug methods $debugDidSettings(did: string): Promise; $debugMergedSettings(did: string): Promise; diff --git a/src/views/ContactEditView.vue b/src/views/ContactEditView.vue index ab9b70f3..7469057a 100644 --- a/src/views/ContactEditView.vue +++ b/src/views/ContactEditView.vue @@ -341,7 +341,7 @@ export default class ContactEditView extends Vue { this.contactMethods = contact.contactMethods || []; // Load labels - const labels = await this.$getContactLabels(contactDid); + const labels = await this.$getContactLabelsForDid(contactDid); this.contactLabels = labels; this.originalLabels = [...labels]; diff --git a/src/views/ContactsView.vue b/src/views/ContactsView.vue index 84d06d1b..6252857d 100644 --- a/src/views/ContactsView.vue +++ b/src/views/ContactsView.vue @@ -226,10 +226,7 @@ import { VerifiableCredential, } from "@/interfaces"; import * as libsUtil from "../libs/util"; -import { - generateSaveAndActivateIdentity, - contactsToExportJson, -} from "../libs/util"; +import { generateSaveAndActivateIdentity } from "../libs/util"; import { logger } from "../utils/logger"; // No longer needed - using PlatformServiceMixin methods // import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; @@ -1455,28 +1452,17 @@ export default class ContactsView extends Vue { /** * Export contact data to JSON file + * Exports contacts and contact labels tables * Uses platform service to handle platform-specific export logic */ private async exportContactData(): Promise { - // Note that similar code is in DataExportSection.vue exportDatabase() try { // Fetch all contacts from database - const allContacts = await this.$contacts(); - - // Convert contacts to export format - const exportData = contactsToExportJson(allContacts); - const jsonStr = JSON.stringify(exportData, null, 2); - - // Generate filename with current date - const today = new Date(); - const dateString = today.toISOString().split("T")[0]; // YYYY-MM-DD format - const fileName = `timesafari-backup-contacts-${dateString}.json`; - - // Use platform service to handle export - await this.platformService.writeAndShareFile(fileName, jsonStr); + // Prepare export data using shared utility function + await this.$saveContactExport(); this.notify.success( - "Contact export completed successfully. Check your downloads or share dialog.", + "Contact export completed successfully. Check downloads or the share dialog.", ); } catch (error) { logger.error("Export Error:", error); diff --git a/src/views/DIDView.vue b/src/views/DIDView.vue index 01b26bb0..a4dd43a8 100644 --- a/src/views/DIDView.vue +++ b/src/views/DIDView.vue @@ -501,7 +501,7 @@ export default class DIDView extends Vue { this.contactYaml = yaml.dump(this.contactFromDid); // Load labels for this contact - this.contactLabels = await this.$getContactLabels(this.viewingDid); + this.contactLabels = await this.$getContactLabelsForDid(this.viewingDid); } else { this.contactFromDid = undefined; this.contactYaml = "";