Compare commits

...

3 Commits

Author SHA1 Message Date
Jose Olarte III
36eb9a16b0 fix: preserve contact methods and notes in export/import workflow
- Fix contactMethods not being exported (was checking for string instead of array)
- Add missing notes and iViewContent fields to $insertContact method
- Normalize empty strings to null when saving contacts in ContactEditView

This ensures contact data integrity is maintained during export/import operations.
2025-11-20 18:11:27 +08:00
Jose Olarte III
203cf6b078 refactor(DataExportSection): rename section title to "Data Management"
Update the section title from "Data Export" to "Data Management" to better reflect that the component handles both data export and import functionality.
2025-11-19 21:32:35 +08:00
Jose Olarte III
9b84b28a78 refactor: move Import Contacts section to DataExportSection component
- Move Import Contacts UI section from AccountViewView to DataExportSection
- Consolidate import/export functionality in a single component
- Move related methods: uploadImportFile, showContactImport, checkContactImports
- Convert module-level ref to component property (inputImportFileName)
- Remove unused imports (ref, ImportContent) from AccountViewView
- Rename "Download Contacts" button to "Export Contacts"
- Improve import UI styling with full-width file input and button
2025-11-19 19:26:36 +08:00
4 changed files with 148 additions and 121 deletions

View File

@@ -10,7 +10,7 @@ messages * - Conditional UI based on platform capabilities * * @component *
<template> <template>
<div id="sectionDataExport" :class="containerClasses"> <div id="sectionDataExport" :class="containerClasses">
<div :class="titleClasses">Data Export</div> <div :class="titleClasses">Data Management</div>
<router-link <router-link
v-if="activeDid" v-if="activeDid"
:to="{ name: 'seed-backup' }" :to="{ name: 'seed-backup' }"
@@ -30,7 +30,7 @@ messages * - Conditional UI based on platform capabilities * * @component *
:class="exportButtonClasses" :class="exportButtonClasses"
@click="exportDatabase()" @click="exportDatabase()"
> >
{{ isExporting ? "Exporting..." : "Download Contacts" }} {{ isExporting ? "Exporting..." : "Export Contacts" }}
</button> </button>
<div <div
@@ -55,11 +55,54 @@ messages * - Conditional UI based on platform capabilities * * @component *
</li> </li>
</ul> </ul>
</div> </div>
<!-- Import Contacts -->
<div id="sectionImportContactsSettings" class="mt-4">
<h2 class="text-slate-500 text-sm font-bold">Import Contacts</h2>
<div class="mt-2">
<input
type="file"
class="w-full bg-white rounded-md pe-2 file:border-0 file:bg-gradient-to-b file:from-blue-400 file:to-blue-700 file:shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] file:text-white file:px-3 file:py-2 file:me-2 file:rounded-s-md"
@change="uploadImportFile"
/>
<transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-4"
enter-to-class="translate-y-0 opacity-100 sm:translate-y-0"
leave-active-class="transition ease-in duration-500"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="showContactImport()" class="mt-2">
<!-- Bulk import has an error
<div class="flex justify-center">
<button
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
@click="confirmSubmitImportFile()"
>
Overwrite Settings & Contacts
<br />
(which doesn't include Identifier Data)
</button>
</div>
-->
<button
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="checkContactImports()"
>
Import Contacts
</button>
</div>
</transition>
</div>
</div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator"; import { Component, Prop, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import * as R from "ramda"; import * as R from "ramda";
import { AppString, NotificationIface } from "../constants/app"; import { AppString, NotificationIface } from "../constants/app";
@@ -67,8 +110,10 @@ import { Contact } from "../db/tables/contacts";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { contactsToExportJson } from "../libs/util"; import { contactsToExportJson } from "../libs/util";
import { createNotifyHelpers } from "@/utils/notify"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { ImportContent } from "@/interfaces/accountView";
/** /**
* @vue-component * @vue-component
@@ -91,6 +136,12 @@ export default class DataExportSection extends Vue {
*/ */
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
/**
* Router instance injected by Vue
* Used for navigation
*/
$router!: Router;
/** /**
* Active DID (Decentralized Identifier) of the user * Active DID (Decentralized Identifier) of the user
* Controls visibility of seed backup option * Controls visibility of seed backup option
@@ -110,6 +161,12 @@ export default class DataExportSection extends Vue {
*/ */
showRedNotificationDot = false; showRedNotificationDot = false;
/**
* Reference to the selected import file
* Used to store the file selected by the user for import
*/
private inputImportFileName: Blob | undefined;
/** /**
* Notification helper for consistent notification patterns * Notification helper for consistent notification patterns
* Created as a getter to ensure $notify is available when called * Created as a getter to ensure $notify is available when called
@@ -200,12 +257,30 @@ export default class DataExportSection extends Vue {
// first remove the contactMethods field, mostly to cast to a clear type (that will end up with JSON objects) // 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); const exContact: Contact = R.omit(["contactMethods"], contact);
// now add contactMethods as a true array of ContactMethod objects // now add contactMethods as a true array of ContactMethod objects
exContact.contactMethods = contact.contactMethods // $contacts() returns normalized contacts where contactMethods is already an array,
? typeof contact.contactMethods === "string" && // but we handle both array and string cases for robustness
contact.contactMethods.trim() !== "" if (contact.contactMethods) {
? JSON.parse(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; return exContact;
}); });
@@ -248,5 +323,58 @@ export default class DataExportSection extends Vue {
this.showRedNotificationDot = false; this.showRedNotificationDot = false;
} }
} }
/**
* Handles file selection for contact import
* Stores the selected file for later processing
*/
async uploadImportFile(event: Event): Promise<void> {
this.inputImportFileName = (event.target as HTMLInputElement).files?.[0];
}
/**
* Checks if a contact import file has been selected
* Used to conditionally show the import button
*/
showContactImport(): boolean {
return !!this.inputImportFileName;
}
/**
* Processes the selected import file and navigates to the contact import view
* Parses the JSON file and extracts contact data for import
*/
async checkContactImports(): Promise<void> {
if (!this.inputImportFileName) {
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const fileContent: string = (event.target?.result as string) || "{}";
try {
const contents: ImportContent = JSON.parse(fileContent);
const contactTableRows: Array<Contact> = (
contents.data?.data as [{ tableName: string; rows: Array<Contact> }]
)?.find((table) => table.tableName === "contacts")
?.rows as Array<Contact>;
const contactRows = contactTableRows.map(
// @ts-expect-error for omitting this field that is found in the Dexie format
(contact) => R.omit(["$types"], contact) as Contact,
);
this.$router.push({
name: "contact-import",
query: { contacts: JSON.stringify(contactRows) },
});
} catch (error) {
logger.error("Error checking contact imports:", error);
this.notify.error(
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMPORT_ERROR,
TIMEOUTS.STANDARD,
);
}
};
reader.readAsText(this.inputImportFileName);
}
} }
</script> </script>

View File

@@ -1367,6 +1367,9 @@ export const PlatformServiceMixin = {
contact.profileImageUrl !== undefined contact.profileImageUrl !== undefined
? contact.profileImageUrl ? contact.profileImageUrl
: null, : null,
notes: contact.notes !== undefined ? contact.notes : null,
iViewContent:
contact.iViewContent !== undefined ? contact.iViewContent : null,
contactMethods: contactMethods:
contact.contactMethods !== undefined contact.contactMethods !== undefined
? Array.isArray(contact.contactMethods) ? Array.isArray(contact.contactMethods)
@@ -1377,8 +1380,8 @@ export const PlatformServiceMixin = {
await this.$dbExec( await this.$dbExec(
`INSERT OR REPLACE INTO contacts `INSERT OR REPLACE INTO contacts
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, contactMethods) (did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, notes, iViewContent, contactMethods)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
safeContact.did, safeContact.did,
safeContact.name, safeContact.name,
@@ -1387,6 +1390,8 @@ export const PlatformServiceMixin = {
safeContact.registered, safeContact.registered,
safeContact.nextPubKeyHashB64, safeContact.nextPubKeyHashB64,
safeContact.profileImageUrl, safeContact.profileImageUrl,
safeContact.notes,
safeContact.iViewContent,
safeContact.contactMethods, safeContact.contactMethods,
], ],
); );

View File

@@ -375,45 +375,6 @@
Switch Identifier Switch Identifier
</router-link> </router-link>
<div id="sectionImportContactsSettings" class="mt-4">
<h2 class="text-slate-500 text-sm font-bold">Import Contacts</h2>
<div class="ml-4 mt-2">
<input type="file" class="ml-2" @change="uploadImportFile" />
<transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-4"
enter-to-class="translate-y-0 opacity-100 sm:translate-y-0"
leave-active-class="transition ease-in duration-500"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="showContactImport()" class="mt-4">
<!-- Bulk import has an error
<div class="flex justify-center">
<button
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
@click="confirmSubmitImportFile()"
>
Overwrite Settings & Contacts
<br />
(which doesn't include Identifier Data)
</button>
</div>
-->
<div class="flex justify-center">
<button
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
@click="checkContactImports()"
>
Import Contacts
</button>
</div>
</div>
</transition>
</div>
</div>
<label <label
for="toggleShowAmounts" for="toggleShowAmounts"
class="flex items-center justify-between cursor-pointer my-4" class="flex items-center justify-between cursor-pointer my-4"
@@ -770,9 +731,7 @@ import "dexie-export-import";
import { ImportProgress } from "dexie-export-import"; import { ImportProgress } from "dexie-export-import";
import { LeafletMouseEvent } from "leaflet"; import { LeafletMouseEvent } from "leaflet";
import * as L from "leaflet"; import * as L from "leaflet";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core"; import { IIdentifier } from "@veramo/core";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router"; import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService"; import { copyToClipboard } from "../services/ClipboardService";
@@ -799,7 +758,6 @@ import {
NotificationIface, NotificationIface,
PASSKEYS_ENABLED, PASSKEYS_ENABLED,
} from "../constants/app"; } from "../constants/app";
import { Contact } from "../db/tables/contacts";
import { import {
DEFAULT_PASSKEY_EXPIRATION_MINUTES, DEFAULT_PASSKEY_EXPIRATION_MINUTES,
BoundingBox, BoundingBox,
@@ -823,11 +781,7 @@ import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView"; import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder"; import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import { import { AccountSettings, isApiError } from "@/interfaces/accountView";
AccountSettings,
isApiError,
ImportContent,
} from "@/interfaces/accountView";
// Profile data interface (inlined from ProfileService) // Profile data interface (inlined from ProfileService)
interface ProfileData { interface ProfileData {
description: string; description: string;
@@ -836,8 +790,6 @@ interface ProfileData {
includeLocation: boolean; includeLocation: boolean;
} }
const inputImportFileNameRef = ref<Blob>();
interface UserNameDialogRef { interface UserNameDialogRef {
open: (cb: (name?: string) => void) => void; open: (cb: (name?: string) => void) => void;
} }
@@ -1369,65 +1321,6 @@ export default class AccountViewView extends Vue {
); );
} }
async uploadImportFile(event: Event): Promise<void> {
inputImportFileNameRef.value = (
event.target as HTMLInputElement
).files?.[0];
}
showContactImport(): boolean {
return !!inputImportFileNameRef.value;
}
confirmSubmitImportFile(): void {
if (inputImportFileNameRef.value != null) {
this.notify.confirm(
ACCOUNT_VIEW_CONSTANTS.WARNINGS.IMPORT_REPLACE_WARNING,
this.submitImportFile,
);
}
}
/**
* Asynchronously imports the database from a downloadable JSON file.
*
* @throws Will notify the user if there is an export error.
*/
async submitImportFile(): Promise<void> {
if (inputImportFileNameRef.value != null) {
// TODO: implement this for SQLite
}
}
async checkContactImports(): Promise<void> {
const reader = new FileReader();
reader.onload = (event) => {
const fileContent: string = (event.target?.result as string) || "{}";
try {
const contents: ImportContent = JSON.parse(fileContent);
const contactTableRows: Array<Contact> = (
contents.data?.data as [{ tableName: string; rows: Array<Contact> }]
)?.find((table) => table.tableName === "contacts")
?.rows as Array<Contact>;
const contactRows = contactTableRows.map(
// @ts-expect-error for omitting this field that is found in the Dexie format
(contact) => R.omit(["$types"], contact) as Contact,
);
(this.$router as Router).push({
name: "contact-import",
query: { contacts: JSON.stringify(contactRows) },
});
} catch (error) {
logger.error("Error checking contact imports:", error);
this.notify.error(
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMPORT_ERROR,
TIMEOUTS.STANDARD,
);
}
};
reader.readAsText(inputImportFileNameRef.value as Blob);
}
private progressCallback(progress: ImportProgress): boolean { private progressCallback(progress: ImportProgress): boolean {
logger.log( logger.log(
`Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`, `Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`,

View File

@@ -338,9 +338,10 @@ export default class ContactEditView extends Vue {
} }
// Save to database via PlatformServiceMixin // Save to database via PlatformServiceMixin
// Normalize empty strings to null to preserve database consistency
await this.$updateContact(this.contact?.did || "", { await this.$updateContact(this.contact?.did || "", {
name: this.contactName, name: this.contactName?.trim() || null,
notes: this.contactNotes, notes: this.contactNotes?.trim() || null,
contactMethods: contactMethods, contactMethods: contactMethods,
}); });