Compare commits

...

2 Commits

Author SHA1 Message Date
e94effd111 import labels from an export 2026-01-14 19:37:37 -07:00
84cad0e169 export labels within contacts 2026-01-13 20:46:03 -07:00
8 changed files with 303 additions and 191 deletions

View File

@@ -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<void> {
// 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);

View File

@@ -45,6 +45,8 @@ export type ContactMaybeWithJsonStrings = Omit<Contact, "contactMethods"> & {
contactMethods?: string | Array<ContactMethod>;
};
export type ContactWithLabels = Contact & { labels?: Array<string> };
export const ContactSchema = {
contacts: "&did, name", // no need to key by other things
};

View File

@@ -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<Record<string, unknown>>;
}
/**
* Interface for the complete database export format
*/
export interface DatabaseExport {
data: {
data: Array<TableExportData>;
};
}
/**
* 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

View File

@@ -14,31 +14,18 @@
* - Mixin pattern for easy integration with existing class components
* - Enhanced utility methods for common patterns
* - Robust error handling and logging
* - Ultra-concise database interaction methods
* - Smart caching layer with TTL for performance optimization
* - Settings shortcuts for ultra-frequent update patterns
* - High-level entity operations (insertContact, updateContact, etc.)
* - Result mapping helpers to eliminate verbose row processing
*
* Benefits:
* - Eliminates repeated PlatformServiceFactory.getInstance() calls
* - Provides consistent error handling across components
* - Reduces boilerplate database code by up to 80%
* - Maintains type safety with TypeScript
* - Includes common database utility patterns
* - Enhanced error handling and logging
* - Ultra-concise method names for frequent operations
* - Automatic caching for settings and contacts (massive performance gain)
* - Settings update shortcuts reduce 90% of update boilerplate
* - Entity operations eliminate verbose SQL INSERT/UPDATE patterns
* - Result mapping helpers reduce row processing boilerplate by 75%
*
* @author Matthew Raymer
* @version 4.1.0
* @since 2025-07-02
* @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 +36,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 +52,7 @@ import {
generateInsertStatement,
generateUpdateStatement,
} from "@/utils/sqlHelpers";
import { AppString } from "../constants/app";
// =================================================
// TYPESCRIPT INTERFACES
@@ -86,6 +78,23 @@ interface VueComponentWithMixin {
platformService(): PlatformService;
}
/**
* Interface for the JSON export format of database tables
*/
export interface TableExportData {
tableName: string;
rows: Array<Record<string, unknown>>;
}
/**
* Interface for the complete database export format
*/
export interface DatabaseExport {
data: {
data: Array<TableExportData>;
};
}
// /**
// * Global cache store for mixin instances
// * Uses WeakMap to avoid memory leaks when components are destroyed
@@ -1340,6 +1349,10 @@ export const PlatformServiceMixin = {
return this._mapColumnsToValues(columns, values);
},
// =================================================
// CONTACT METHODS
// =================================================
/**
* Insert or replace contact - $insertContact()
* Eliminates verbose INSERT OR REPLACE patterns
@@ -1496,12 +1509,16 @@ export const PlatformServiceMixin = {
}
},
// =================================================
// CONTACT LABELS METHODS
// =================================================
/**
* Get labels for a specific contact - $getContactLabels()
* @param did Contact DID
* @returns Promise<string[]> Array of labels
*/
async $getContactLabels(did: string): Promise<string[]> {
async $getContactLabelsForDid(did: string): Promise<string[]> {
try {
const results = (await this.$dbQuery(
"SELECT label FROM contact_labels WHERE did = ? ORDER BY label",
@@ -1598,11 +1615,54 @@ export const PlatformServiceMixin = {
}
},
async $insertContactLabels(
did: string,
labels: string[],
): Promise<boolean> {
try {
for (const label of labels) {
await this.$dbExec(
"INSERT INTO contact_labels (did, label) VALUES (?, ?)",
[did, label],
);
}
return true;
} catch (error) {
logger.error(
`[PlatformServiceMixin] Error inserting labels for contact ${did}:`,
error,
);
return false;
}
},
async $updateContactLabels(
did: string,
labels: string[],
): Promise<boolean> {
try {
await this.$dbExec("DELETE FROM contact_labels WHERE did = ?", [did]);
for (const label of labels) {
await this.$dbExec(
"INSERT INTO contact_labels (did, label) VALUES (?, ?)",
[did, label],
);
}
return true;
} catch (error) {
logger.error(
`[PlatformServiceMixin] Error updating labels for contact ${did}:`,
error,
);
return false;
}
},
/**
* Get all unique labels available - $getUniqueLabels()
* Get all unique labels available
* @returns Promise<string[]> Array of unique labels
*/
async $getUniqueLabels(): Promise<string[]> {
async $getUniqueContactLabels(): Promise<string[]> {
try {
const results = (await this.$dbQuery(
"SELECT DISTINCT label FROM contact_labels ORDER BY label",
@@ -1952,6 +2012,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<string> {
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<string, string[]>();
// 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,11 +2273,13 @@ export interface IPlatformServiceMixin {
$getContact(did: string): Promise<Contact | null>;
$deleteContact(did: string): Promise<boolean>;
$contactCount(): Promise<number>;
$getContactLabels(did: string): Promise<string[]>;
$getContactLabelsForDid(did: string): Promise<string[]>;
$getContactIdsWithAllLabels(labels: string[]): Promise<string[]>;
$addContactLabel(did: string, label: string): Promise<boolean>;
$deleteContactLabel(did: string, label: string): Promise<boolean>;
$getUniqueLabels(): Promise<string[]>;
$insertContactLabels(did: string, labels: string[]): Promise<boolean>;
$updateContactLabels(did: string, labels: string[]): Promise<boolean>;
$getUniqueContactLabels(): Promise<string[]>;
$getAllAccounts(): Promise<Account[]>;
$getAllAccountDids(): Promise<string[]>;
$insertEntity(
@@ -2146,6 +2335,9 @@ export interface IPlatformServiceMixin {
values: unknown[][],
): Array<Record<string, unknown>>;
// Contact export methods
$saveContactExport(): Promise<string>;
// Debug methods
$debugDidSettings(did: string): Promise<Settings | null>;
$debugMergedSettings(did: string): Promise<void>;
@@ -2233,11 +2425,13 @@ declare module "@vue/runtime-core" {
$getContact(did: string): Promise<Contact | null>;
$deleteContact(did: string): Promise<boolean>;
$contactCount(): Promise<number>;
$getContactLabels(did: string): Promise<string[]>;
$getContactLabelsForDid(did: string): Promise<string[]>;
$getContactIdsWithAllLabels(labels: string[]): Promise<string[]>;
$addContactLabel(did: string, label: string): Promise<boolean>;
$deleteContactLabel(did: string, label: string): Promise<boolean>;
$getUniqueLabels(): Promise<string[]>;
$insertContactLabels(did: string, labels: string[]): Promise<boolean>;
$updateContactLabels(did: string, labels: string[]): Promise<boolean>;
$getUniqueContactLabels(): Promise<string[]>;
$getAllAccounts(): Promise<Account[]>;
$getAllAccountDids(): Promise<string[]>;
$insertEntity(
@@ -2293,6 +2487,9 @@ declare module "@vue/runtime-core" {
values: unknown[][],
): Array<Record<string, unknown>>;
// Contact export methods
$saveContactExport(): Promise<string>;
// Debug methods
$debugDidSettings(did: string): Promise<Settings | null>;
$debugMergedSettings(did: string): Promise<void>;

View File

@@ -341,12 +341,12 @@ 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];
// Load all labels for suggestions
this.allUniqueLabels = await this.$getUniqueLabels();
this.allUniqueLabels = await this.$getUniqueContactLabels();
} else {
this.notify.error(
`${NOTIFY_CONTACT_NOT_FOUND.message} ${contactDid}`,

View File

@@ -87,8 +87,14 @@
<div class="border font-bold p-1">
{{ capitalizeAndInsertSpacesBeforeCaps(contactField) }}
</div>
<div class="border p-1">{{ value.old }}</div>
<div class="border p-1">{{ value.new }}</div>
<div v-if="contactField === 'labels'" class="border p-1">
{{ value.old.join(", ") }}
</div>
<div v-else class="border p-1">{{ value.old }}</div>
<div v-if="contactField === 'labels'" class="border p-1">
{{ value.new.join(", ") }}
</div>
<div v-else class="border p-1">{{ value.new }}</div>
</div>
</div>
</div>
@@ -176,26 +182,6 @@
* - Field-by-field comparison for existing contacts
* - Batch visibility settings
* - Auto-import for single new contacts
* - Error handling and validation
*
* State Management:
* - Tracks existing contacts
* - Maintains selection state for bulk imports
* - Records differences for duplicate contacts
* - Manages visibility settings
*
* Security Considerations:
* - JWT validation for imported contacts
* - Visibility control per contact
* - Error handling for malformed data
*
* @example
* // Component usage in router
* {
* path: "/contact-import/:jwt?",
* name: "contact-import",
* component: ContactImportView
* }
*
* @see {@link Contact} for contact data structure
* @see {@link setVisibilityUtil} for visibility management
@@ -209,7 +195,11 @@ import QuickNav from "../components/QuickNav.vue";
import EntityIcon from "../components/EntityIcon.vue";
import OfferDialog from "../components/OfferDialog.vue";
import { APP_SERVER, AppString, NotificationIface } from "../constants/app";
import { Contact, ContactMethod } from "../db/tables/contacts";
import {
Contact,
ContactWithLabels,
ContactMethod,
} from "../db/tables/contacts";
import * as libsUtil from "../libs/util";
import {
capitalizeAndInsertSpacesBeforeCaps,
@@ -220,6 +210,25 @@ import { getContactJwtFromJwtUrl } from "../libs/crypto";
import { decodeEndorserJwt } from "../libs/crypto/vc";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { ContactLabel } from "@/db/tables/contactLabels";
type ContactDifferences = Record<
string,
{
new:
| string
| boolean
| Array<ContactMethod>
| Array<ContactLabel>
| undefined;
old:
| string
| boolean
| Array<ContactMethod>
| Array<ContactLabel>
| undefined;
}
>;
/**
* Contact Import View Component
@@ -287,22 +296,13 @@ export default class ContactImportView extends Vue {
/** API server URL for backend communication */
apiServer = "";
/** Map of existing contacts keyed by DID for duplicate detection */
contactsExisting: Record<string, Contact> = {};
contactsExisting: Record<string, ContactWithLabels> = {};
/** Array of contacts being imported from JWT */
contactsImporting: Array<Contact> = [];
contactsImporting: Array<ContactWithLabels> = [];
/** Selection state for each importing contact */
contactsSelected: Array<boolean> = [];
/** Differences between existing and importing contacts */
contactDifferences: Record<
string,
Record<
string,
{
new: string | boolean | Array<ContactMethod> | undefined;
old: string | boolean | Array<ContactMethod> | undefined;
}
>
> = {};
/** Each contact's differences between existing and importing info */
contactDifferences: Record<string, ContactDifferences> = {};
/** Loading state for import operations */
checkingImports = false;
/** JWT input for manual contact import */
@@ -412,13 +412,17 @@ export default class ContactImportView extends Vue {
* Processes contacts for import and checks for duplicates
* @param contacts Array of contacts to process
*/
async setContactsSelected(contacts: Array<Contact>) {
async setContactsSelected(contacts: Array<ContactWithLabels>) {
this.contactsImporting = contacts;
this.contactsSelected = new Array(this.contactsImporting.length).fill(true);
this.contactsSelected = new Array(this.contactsImporting.length).fill(
false,
);
// Get all existing contacts for comparison
const baseContacts = await this.$getAllContacts();
// get the labels for each contact
// Check for existing contacts and differences
for (let i = 0; i < this.contactsImporting.length; i++) {
const contactIn = this.contactsImporting[i];
@@ -426,25 +430,24 @@ export default class ContactImportView extends Vue {
(contact) => contact.did === contactIn.did,
);
if (existingContact) {
this.contactsExisting[contactIn.did] = existingContact;
const labels = await this.$getContactLabelsForDid(existingContact.did);
this.contactsExisting[contactIn.did] = {
...existingContact,
labels: labels || [],
};
const existingFullContact = this.contactsExisting[contactIn.did];
// Compare contact fields for differences
const differences: Record<
string,
{
new: string | boolean | Array<ContactMethod> | undefined;
old: string | boolean | Array<ContactMethod> | undefined;
}
> = {};
const differences: ContactDifferences = {};
Object.keys(contactIn).forEach((key) => {
if (
!R.equals(
contactIn[key as keyof Contact],
existingContact[key as keyof Contact],
existingFullContact[key as keyof Contact],
)
) {
differences[key] = {
old: existingContact[key as keyof Contact],
old: existingFullContact[key as keyof Contact],
new: contactIn[key as keyof Contact],
};
}
@@ -452,10 +455,13 @@ export default class ContactImportView extends Vue {
this.contactDifferences[contactIn.did] = differences;
if (R.isEmpty(differences)) {
this.sameCount++;
} else {
// auto-select contacts with differences
this.contactsSelected[i] = true;
}
// Don't auto-select duplicates
this.contactsSelected[i] = false;
} else {
// auto-select new contacts
this.contactsSelected[i] = true;
}
}
}
@@ -517,16 +523,25 @@ export default class ContactImportView extends Vue {
// Process selected contacts
for (let i = 0; i < this.contactsImporting.length; i++) {
if (this.contactsSelected[i]) {
const contact = this.contactsImporting[i];
const contactWithLabels = this.contactsImporting[i];
const contact = {
...contactWithLabels,
labels: undefined,
};
const contactLabels = contactWithLabels.labels || [];
const existingContact = this.contactsExisting[contact.did];
if (existingContact) {
// Update existing contact
await this.$updateContact(contact.did, contact);
// update the labels for the contact
await this.$updateContactLabels(contact.did, contactLabels);
updatedCount++;
} else {
// Add new contact
await this.$insertContact(contact);
// add the labels for the contact
await this.$insertContactLabels(contact.did, contactLabels);
importedCount++;
}
}

View File

@@ -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";
@@ -390,7 +387,7 @@ export default class ContactsView extends Vue {
this.contacts = await this.$getAllContacts();
this.contactsFiltered = await this.filteredContacts();
this.allLabels = await this.$getUniqueLabels();
this.allLabels = await this.$getUniqueContactLabels();
}
private async processContactJwt() {
@@ -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<void> {
// 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);

View File

@@ -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 = "";