Compare commits

..

5 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
7abce8f95c fix: don't count any changed projects on the front page that had blank differences 2025-11-18 19:53:52 -07:00
88dce4d100 fix: show a "project changed" entry if the server reports something 2025-11-18 19:49:40 -07:00
9 changed files with 186 additions and 136 deletions

View File

@@ -10,7 +10,7 @@ messages * - Conditional UI based on platform capabilities * * @component *
<template>
<div id="sectionDataExport" :class="containerClasses">
<div :class="titleClasses">Data Export</div>
<div :class="titleClasses">Data Management</div>
<router-link
v-if="activeDid"
:to="{ name: 'seed-backup' }"
@@ -30,7 +30,7 @@ messages * - Conditional UI based on platform capabilities * * @component *
:class="exportButtonClasses"
@click="exportDatabase()"
>
{{ isExporting ? "Exporting..." : "Download Contacts" }}
{{ isExporting ? "Exporting..." : "Export Contacts" }}
</button>
<div
@@ -55,11 +55,54 @@ messages * - Conditional UI based on platform capabilities * * @component *
</li>
</ul>
</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>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import * as R from "ramda";
import { AppString, NotificationIface } from "../constants/app";
@@ -67,8 +110,10 @@ import { Contact } from "../db/tables/contacts";
import { logger } from "../utils/logger";
import { contactsToExportJson } from "../libs/util";
import { createNotifyHelpers } from "@/utils/notify";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { ImportContent } from "@/interfaces/accountView";
/**
* @vue-component
@@ -91,6 +136,12 @@ export default class DataExportSection extends Vue {
*/
$notify!: (notification: NotificationIface, timeout?: number) => void;
/**
* Router instance injected by Vue
* Used for navigation
*/
$router!: Router;
/**
* Active DID (Decentralized Identifier) of the user
* Controls visibility of seed backup option
@@ -110,6 +161,12 @@ export default class DataExportSection extends Vue {
*/
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
* 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)
const exContact: Contact = R.omit(["contactMethods"], contact);
// now add contactMethods as a true array of ContactMethod objects
exContact.contactMethods = contact.contactMethods
? typeof contact.contactMethods === "string" &&
contact.contactMethods.trim() !== ""
? JSON.parse(contact.contactMethods)
: []
: [];
// $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;
});
@@ -248,5 +323,58 @@ export default class DataExportSection extends Vue {
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>

View File

@@ -483,13 +483,10 @@ export default class GiftedDialog extends Vue {
image: project.image,
handleId: project.handleId,
};
// Only set receiver to "You" if no receiver has been selected yet
if (!this.receiver || !this.receiver.did) {
this.receiver = {
did: this.activeDid,
name: "You",
};
}
this.receiver = {
did: this.activeDid,
name: "You",
};
this.firstStep = false;
}

View File

@@ -57,7 +57,12 @@ export interface OfferToPlanSummaryRecord extends OfferSummaryRecord {
planName: string;
}
// a summary record; the VC is not currently part of this record
/**
* A summary record
* The VC is not currently part of this record.
*
* If you change this, you may want to update NewActivityView.vue to handle differences correctly.
*/
export interface PlanSummaryRecord {
agentDid?: string;
description: string;
@@ -76,7 +81,9 @@ export interface PlanSummaryRecord {
export interface PlanSummaryAndPreviousClaim {
plan: PlanSummaryRecord;
wrappedClaimBefore: GenericCredWrapper<PlanActionClaim>;
// This can be undefined, eg. if a project is starred after the stored last-seen-change-jwt ID.
// The endorser-ch test code shows some cases.
wrappedClaimBefore?: GenericCredWrapper<PlanActionClaim>;
}
/**

View File

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

View File

@@ -375,45 +375,6 @@
Switch Identifier
</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
for="toggleShowAmounts"
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 { LeafletMouseEvent } from "leaflet";
import * as L from "leaflet";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService";
@@ -799,7 +758,6 @@ import {
NotificationIface,
PASSKEYS_ENABLED,
} from "../constants/app";
import { Contact } from "../db/tables/contacts";
import {
DEFAULT_PASSKEY_EXPIRATION_MINUTES,
BoundingBox,
@@ -823,11 +781,7 @@ import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import {
AccountSettings,
isApiError,
ImportContent,
} from "@/interfaces/accountView";
import { AccountSettings, isApiError } from "@/interfaces/accountView";
// Profile data interface (inlined from ProfileService)
interface ProfileData {
description: string;
@@ -836,8 +790,6 @@ interface ProfileData {
includeLocation: boolean;
}
const inputImportFileNameRef = ref<Blob>();
interface UserNameDialogRef {
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 {
logger.log(
`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
// Normalize empty strings to null to preserve database consistency
await this.$updateContact(this.contact?.did || "", {
name: this.contactName,
notes: this.contactNotes,
name: this.contactName?.trim() || null,
notes: this.contactNotes?.trim() || null,
contactMethods: contactMethods,
});

View File

@@ -898,7 +898,13 @@ export default class HomeView extends Vue {
this.starredPlanHandleIds,
this.lastAckedStarredPlanChangesJwtId,
);
this.numNewStarredProjectChanges = starredProjectChanges.data.length;
// filter out any data elements where there is no wrappedClaimBefore
const filteredNewStarredProjectChanges =
starredProjectChanges.data.filter(
(change) => change.wrappedClaimBefore !== undefined,
);
this.numNewStarredProjectChanges =
filteredNewStarredProjectChanges.length;
this.newStarredProjectChangesHitLimit = starredProjectChanges.hitLimit;
} catch (error) {
// Don't show errors for starred project changes as it's a secondary feature

View File

@@ -284,7 +284,10 @@
</table>
</div>
</div>
<div v-else>The changes did not affect essential project data.</div>
<div v-else>
The changes are not important, like it was saved by accident or
you've seen it all before.
</div>
<!-- New line that appears on hover -->
<div
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
@@ -589,13 +592,13 @@ export default class NewActivityView extends Vue {
for (const planChange of planChanges) {
const currentPlan: PlanSummaryRecord = planChange.plan;
const wrappedClaim: GenericCredWrapper<PlanActionClaim> =
const wrappedClaim: GenericCredWrapper<PlanActionClaim> | undefined =
planChange.wrappedClaimBefore;
// Extract the actual claim from the wrapped claim
let previousClaim: PlanActionClaim;
let previousClaim: PlanActionClaim | undefined;
const embeddedClaim: PlanActionClaim = wrappedClaim.claim;
const embeddedClaim: PlanActionClaim | undefined = wrappedClaim?.claim;
if (
embeddedClaim &&
typeof embeddedClaim === "object" &&
@@ -609,7 +612,9 @@ export default class NewActivityView extends Vue {
previousClaim = embeddedClaim;
}
if (!previousClaim || !currentPlan.handleId) {
if (!previousClaim) {
// Can happen when a project is starred after the stored last-seen-change-jwt ID
// so we'll just leave the message saying there are no important differences.
continue;
}

View File

@@ -57,6 +57,9 @@
<button :class="sqlLinkClasses" @click="setAccountsQuery">
Accounts
</button>
<button :class="sqlLinkClasses" @click="setActiveIdentityQuery">
Active DID
</button>
<button :class="sqlLinkClasses" @click="setContactsQuery">
Contacts
</button>
@@ -525,6 +528,11 @@ export default class Help extends Vue {
this.executeSql();
}
setActiveIdentityQuery() {
this.sqlQuery = "SELECT * FROM active_identity;";
this.executeSql();
}
setContactsQuery() {
this.sqlQuery = "SELECT * FROM contacts;";
this.executeSql();