wallet-csv.mjs
704 lines 24.1 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Wallet / exchange transaction history CSV importer.
3 *
4 * Converts wallet export files into vault notes with blockchain frontmatter.
5 * Each CSV row → one note under inbox/wallet-import/<date>-<tx_hash_prefix>.md
6 *
7 * Handles multiple wallet/exchange export formats via a column alias table.
8 * Generic parser is the primary path; named parsers (Coinbase, Exodus, …) are
9 * thin pre-normalizers that rename columns before the generic parse.
10 */
11
12 import fs from 'fs';
13 import path from 'path';
14 import { writeNote } from '../write.mjs';
15 import { normalizeSlug } from '../vault.mjs';
16
17 // ---------------------------------------------------------------------------
18 // Column alias table
19 // Keys are canonical field names; values are ordered arrays of case-insensitive
20 // column header aliases. First match wins.
21 // ---------------------------------------------------------------------------
22 const COLUMN_ALIASES = {
23 tx_hash: ['txhash', 'transaction_hash', 'hash', 'tx id', 'txid', 'transaction id', 'transaction_id'],
24 date: ['date', 'timestamp', 'time', 'confirmed at', 'confirmed_at', 'block time', 'block_time'],
25 amount: ['amount', 'value', 'quantity'],
26 currency: ['currency', 'asset', 'token', 'coin', 'symbol'],
27 direction: ['type', 'direction', 'side'],
28 payment_status: ['status'],
29 wallet_address: ['from', 'to', 'address', 'wallet', 'sender', 'recipient', 'from_address', 'to_address'],
30 network: ['network', 'chain', 'blockchain'],
31 block_height: ['block', 'block number', 'block_number', 'block height', 'block_height'],
32 };
33
34 // ---------------------------------------------------------------------------
35 // Direction normalisation
36 // ---------------------------------------------------------------------------
37 const DIRECTION_MAP = {
38 buy: 'received',
39 receive: 'received',
40 received: 'received',
41 in: 'received',
42 deposit: 'received',
43 // staking / earn rewards are inflows
44 'staking reward': 'received',
45 'staking rewards': 'received',
46 'earn interest': 'received',
47 earn: 'received',
48 reward: 'received',
49 airdrop: 'received',
50 sell: 'sent',
51 send: 'sent',
52 sent: 'sent',
53 out: 'sent',
54 withdrawal: 'sent',
55 withdraw: 'sent',
56 // swap/trade — direction depends on perspective; leave as-is for user review
57 swap: 'swap',
58 trade: 'trade',
59 exchange: 'swap',
60 convert: 'swap',
61 };
62
63 // ---------------------------------------------------------------------------
64 // Payment status normalisation
65 // ---------------------------------------------------------------------------
66 const STATUS_MAP = {
67 completed: 'settled',
68 complete: 'settled',
69 success: 'settled',
70 succeeded: 'settled',
71 confirmed: 'settled',
72 settled: 'settled',
73 pending: 'pending',
74 failed: 'failed',
75 failure: 'failed',
76 error: 'failed',
77 rejected: 'failed',
78 cancelled: 'cancelled',
79 canceled: 'cancelled',
80 };
81
82 // ---------------------------------------------------------------------------
83 // Named wallet format pre-normalisers
84 // Each returns a header array and row-object mapper so the generic parser
85 // gets consistently named columns.
86 // ---------------------------------------------------------------------------
87
88 /**
89 * Coinbase standard export
90 * Headers: Date, Transaction Type, Asset, Quantity Transacted, Price Currency,
91 * Price At Transaction, Subtotal, Total (inclusive of fees and/or spread),
92 * Fees and/or Spread, Notes
93 */
94 function normalizeCoinbase(header, row) {
95 return {
96 date: row['Date'] || row['Timestamp'] || '',
97 type: row['Transaction Type'] || '',
98 currency: row['Asset'] || '',
99 amount: row['Quantity Transacted'] || '',
100 status: 'settled',
101 network: 'coinbase',
102 };
103 }
104
105 /**
106 * Coinbase Pro / Advanced Trade export
107 * Headers: portfolio, type, time, amount, balance, amount/balance unit, …
108 */
109 function normalizeCoinbasePro(header, row) {
110 const unit = row['amount/balance unit'] || row['amount_balance_unit'] || '';
111 return {
112 date: row['time'] || '',
113 type: row['type'] || '',
114 amount: row['amount'] || '',
115 currency: unit,
116 network: 'coinbase-pro',
117 };
118 }
119
120 /**
121 * Exodus wallet export
122 * Headers: DATE, TYPE, FROMAMOUNT, FROMCURRENCY, TOAMOUNT, TOCURRENCY,
123 * TXID, STATUS, …
124 */
125 function normalizeExodus(header, row) {
126 return {
127 txhash: row['TXID'] || '',
128 date: row['DATE'] || '',
129 type: row['TYPE'] || '',
130 amount: row['FROMAMOUNT'] || row['TOAMOUNT'] || '',
131 currency: row['FROMCURRENCY'] || row['TOCURRENCY'] || '',
132 status: row['STATUS'] || '',
133 };
134 }
135
136 /**
137 * ICP Rosetta standard export
138 * Headers: hash, block_index, timestamp, type, account, amount, fee
139 */
140 function normalizeICPRosetta(header, row) {
141 return {
142 txhash: row['hash'] || '',
143 block: row['block_index'] || '',
144 timestamp: row['timestamp'] || '',
145 type: row['type'] || '',
146 address: row['account'] || '',
147 amount: row['amount'] || '',
148 currency: 'ICP',
149 network: 'icp',
150 };
151 }
152
153 /**
154 * Kraken export
155 * Headers: txid, refid, time, type, subtype, aclass, asset, amount, fee, balance
156 * Notes:
157 * - `txid` may be empty for some row types; fall back to `refid` (Kraken's internal ref).
158 * - `asset` uses Kraken ticker notation (XXBT=BTC, XETH=ETH, ZUSD=USD); we strip leading
159 * X/Z for common cases so the currency field stays readable.
160 * - `network` is hardcoded to 'kraken' because the CSV carries no chain information.
161 */
162 function normalizeKraken(header, row) {
163 const rawAsset = row['asset'] || '';
164 // Strip Kraken's X/Z prefix from common assets (XXBT→BTC, XETH→ETH, ZUSD→USD)
165 const currency = rawAsset.replace(/^X(XBT|ETH|LTC|XRP|XDG|ZEC|XMR|REP)$/i, (_, a) => a === 'XBT' ? 'BTC' : a)
166 .replace(/^Z(USD|EUR|GBP|JPY|CAD|AUD|CHF)$/i, (_, a) => a)
167 || rawAsset;
168 return {
169 txhash: row['txid'] || row['refid'] || '',
170 date: row['time'] || '',
171 type: row['type'] || row['subtype'] || '',
172 amount: row['amount'] || '',
173 currency,
174 network: 'kraken',
175 };
176 }
177
178 /**
179 * Binance deposit/withdrawal history export
180 * Headers: Date(UTC), Coin, Network, Amount, TransactionFee, Address, TXID, Status, Remark
181 *
182 * Binance also exports a "spot wallet history" format with UTC_Time / Operation / Coin / Change.
183 * Both are handled: detectFormat distinguishes them by header shape.
184 */
185 function normalizeBinance(header, row) {
186 const h = header.map((c) => c.toLowerCase());
187 // Spot wallet history: UTC_Time, Account, Operation, Coin, Change, Remark
188 if (h.includes('utc_time') || h.includes('operation')) {
189 const change = parseFloat(row['Change'] || '0');
190 return {
191 date: row['UTC_Time'] || '',
192 type: change >= 0 ? 'deposit' : 'withdrawal',
193 currency: row['Coin'] || '',
194 amount: String(Math.abs(change)),
195 network: 'binance',
196 };
197 }
198 // Deposit/withdrawal history: Date(UTC), Coin, Network, Amount, …, TXID, Status
199 return {
200 txhash: row['TXID'] || '',
201 date: row['Date(UTC)'] || '',
202 type: header.some((h2) => /withdrawal/i.test(h2)) ? 'withdrawal' : 'deposit',
203 currency: row['Coin'] || '',
204 amount: row['Amount'] || '',
205 address: row['Address'] || '',
206 network: (row['Network'] || 'binance').toLowerCase(),
207 status: row['Status'] || '',
208 };
209 }
210
211 /**
212 * MetaMask / Etherscan address export
213 * Headers: Txhash, Blockno, UnixTimestamp, DateTime (UTC), From, To,
214 * ContractAddress, Value_IN(ETH), Value_OUT(ETH), CurrentValue @ $...,
215 * TxnFee(ETH), TxnFee(USD), Historical $Price/Eth, Status, ErrCode, Method
216 *
217 * Direction is inferred from Value_IN vs Value_OUT since the user's address
218 * is not available in the CSV itself.
219 */
220 function normalizeMetaMask(header, row) {
221 const valueIn = parseFloat(row['Value_IN(ETH)'] || '0');
222 const valueOut = parseFloat(row['Value_OUT(ETH)'] || '0');
223 const direction = valueIn > 0 ? 'received' : valueOut > 0 ? 'sent' : '';
224 const amount = String(valueIn > 0 ? valueIn : valueOut);
225 const rawStatus = (row['Status'] || '').toLowerCase();
226 // Etherscan encodes failed txs with non-empty ErrCode or Status ''
227 const status = rawStatus === '' && row['ErrCode'] ? 'failed'
228 : rawStatus === '' ? 'settled'
229 : rawStatus;
230 return {
231 txhash: row['Txhash'] || '',
232 block: row['Blockno'] || '',
233 // Prefer the human-readable column; fall back to Unix timestamp
234 date: row['DateTime (UTC)'] || row['UnixTimestamp'] || '',
235 type: direction,
236 from: row['From'] || '',
237 to: row['To'] || '',
238 amount,
239 currency: 'ETH',
240 network: 'ethereum',
241 status,
242 };
243 }
244
245 /**
246 * Phantom wallet export (Solana)
247 * Headers: Transaction ID, Date, Type, Amount, Token, Status, Fee (SOL), Signature
248 *
249 * `Signature` is the canonical Solana tx identifier (base58, 88 chars).
250 * `Transaction ID` may be the same value or a short alias.
251 */
252 function normalizePhantom(header, row) {
253 return {
254 txhash: row['Signature'] || row['Transaction ID'] || '',
255 date: row['Date'] || '',
256 type: row['Type'] || '',
257 amount: row['Amount'] || '',
258 currency: row['Token'] || 'SOL',
259 status: row['Status'] || '',
260 network: 'solana',
261 };
262 }
263
264 /**
265 * Ledger Live export
266 * Headers: Operation Date, Currency ticker, Operation Amount, Operation Fees,
267 * Operation Hash, Account Name, Account xpub, Countervalue Ticker,
268 * Countervalue at Operation Date, Countervalue now
269 *
270 * `Operation Amount` is signed: positive = received, negative = sent.
271 * Currency ticker maps directly to the frontmatter `currency` field.
272 */
273 function normalizeLedger(header, row) {
274 const rawAmount = row['Operation Amount'] || '';
275 const parsed = parseFloat(rawAmount);
276 const direction = isNaN(parsed) ? '' : parsed >= 0 ? 'received' : 'sent';
277 const ticker = (row['Currency ticker'] || '').toUpperCase();
278 // Infer network from common tickers
279 const TICKER_NETWORK = {
280 BTC: 'bitcoin', ETH: 'ethereum', SOL: 'solana', ICP: 'icp',
281 BNB: 'bsc', MATIC: 'polygon', AVAX: 'avalanche', DOT: 'polkadot',
282 ADA: 'cardano', XRP: 'xrp', LTC: 'litecoin', DOGE: 'dogecoin',
283 };
284 return {
285 txhash: row['Operation Hash'] || '',
286 date: row['Operation Date'] || '',
287 type: direction,
288 amount: String(Math.abs(parsed) || rawAmount),
289 currency: ticker,
290 network: TICKER_NETWORK[ticker] || ticker.toLowerCase() || 'ledger',
291 address: row['Account xpub'] || '',
292 };
293 }
294
295 /**
296 * Detect named wallet format by inspecting the raw header row.
297 * Returns a normaliser function, or null for generic processing.
298 * Checks are ordered most-specific → least-specific so distinctive headers
299 * take precedence over partial matches.
300 * @param {string[]} header
301 * @returns {((header: string[], row: Record<string,string>) => Record<string,string>) | null}
302 */
303 function detectFormat(header) {
304 const h = header.map((c) => c.toLowerCase().trim());
305
306 // Ledger Live — "operation date" + "currency ticker" are unique to Ledger
307 if (h.includes('operation date') && h.includes('currency ticker')) return normalizeLedger;
308
309 // MetaMask / Etherscan — signed value columns are unique
310 if (h.includes('value_in(eth)') || h.includes('value_out(eth)') || h.includes('blockno')) return normalizeMetaMask;
311
312 // Phantom — "signature" or "fee (sol)" alongside "token"
313 if ((h.includes('signature') || h.includes('fee (sol)')) && h.includes('token')) return normalizePhantom;
314
315 // Kraken — "refid" + "aclass" are specific to Kraken ledger exports
316 if (h.includes('refid') && (h.includes('aclass') || h.includes('asset'))) return normalizeKraken;
317
318 // Binance — "coin" + either "date(utc)" or "utc_time"
319 if (h.includes('coin') && (h.includes('date(utc)') || h.includes('utc_time'))) return normalizeBinance;
320
321 // Coinbase standard — "quantity transacted" is unique
322 if (h.includes('quantity transacted') || (h.includes('transaction type') && h.includes('asset'))) return normalizeCoinbase;
323
324 // Exodus — "fromamount" + "fromcurrency"
325 if (h.includes('fromamount') && h.includes('fromcurrency')) return normalizeExodus;
326
327 // ICP Rosetta — narrow column set with "block_index"
328 if (h.includes('block_index') && h.includes('hash') && header.length <= 10) return normalizeICPRosetta;
329
330 // Coinbase Pro / Advanced Trade — "portfolio" + "amount/balance unit"
331 if (h.includes('portfolio') && h.includes('amount/balance unit')) return normalizeCoinbasePro;
332
333 return null;
334 }
335
336 // ---------------------------------------------------------------------------
337 // CSV parser (handles quoted fields, escaped quotes)
338 // ---------------------------------------------------------------------------
339 function parseCSVLine(line) {
340 const out = [];
341 let i = 0;
342 while (i < line.length) {
343 if (line[i] === '"') {
344 i++;
345 let field = '';
346 while (i < line.length) {
347 if (line[i] === '"') {
348 i++;
349 if (line[i] === '"') {
350 field += '"';
351 i++;
352 } else break;
353 } else {
354 field += line[i++];
355 }
356 }
357 out.push(field.trim());
358 if (i < line.length && line[i] === ',') i++;
359 } else {
360 let field = '';
361 while (i < line.length && line[i] !== ',') {
362 field += line[i++];
363 }
364 out.push(field.trim());
365 if (line[i] === ',') i++;
366 }
367 }
368 return out;
369 }
370
371 /**
372 * Parse entire CSV text into an array of row-objects keyed by header names.
373 * @param {string} text
374 * @returns {{ header: string[], rows: Record<string,string>[] }}
375 */
376 function parseCSV(text) {
377 const lines = text.split(/\r?\n/).filter((l) => l.trim());
378 if (lines.length < 1) return { header: [], rows: [] };
379 const header = parseCSVLine(lines[0]);
380 const rows = [];
381 for (let i = 1; i < lines.length; i++) {
382 const cols = parseCSVLine(lines[i]);
383 if (cols.every((c) => !c)) continue; // skip blank rows
384 const obj = {};
385 for (let j = 0; j < header.length; j++) {
386 obj[header[j]] = cols[j] || '';
387 }
388 rows.push(obj);
389 }
390 return { header, rows };
391 }
392
393 // ---------------------------------------------------------------------------
394 // Column resolution helpers
395 // ---------------------------------------------------------------------------
396
397 /**
398 * Build a lookup: canonical field → actual column name present in this CSV's header.
399 * Case-insensitive; first alias match wins.
400 * @param {string[]} header
401 * @returns {Record<string, string>}
402 */
403 function buildColumnMap(header) {
404 const lower = header.map((h) => h.toLowerCase().trim());
405 const result = {};
406 for (const [canonical, aliases] of Object.entries(COLUMN_ALIASES)) {
407 for (const alias of aliases) {
408 const idx = lower.indexOf(alias.toLowerCase());
409 if (idx !== -1) {
410 result[canonical] = header[idx];
411 break;
412 }
413 }
414 }
415 return result;
416 }
417
418 /**
419 * Extract canonical field value from a row-object.
420 * @param {Record<string,string>} row
421 * @param {Record<string,string>} colMap
422 * @param {string} canonical
423 * @returns {string}
424 */
425 function get(row, colMap, canonical) {
426 const key = colMap[canonical];
427 if (!key) return '';
428 return (row[key] || '').trim();
429 }
430
431 // ---------------------------------------------------------------------------
432 // Date normalisation
433 // ---------------------------------------------------------------------------
434 function normalizeDate(v) {
435 if (!v) return null;
436 // Handle Unix timestamps (seconds or milliseconds)
437 if (/^\d{10}$/.test(v.trim())) {
438 return new Date(parseInt(v, 10) * 1000).toISOString();
439 }
440 if (/^\d{13}$/.test(v.trim())) {
441 return new Date(parseInt(v, 10)).toISOString();
442 }
443 const d = new Date(v);
444 if (isNaN(d.getTime())) return null;
445 return d.toISOString();
446 }
447
448 function isoToDateStr(iso) {
449 if (!iso) return null;
450 return iso.slice(0, 10);
451 }
452
453 // ---------------------------------------------------------------------------
454 // Slug / filename helpers
455 // ---------------------------------------------------------------------------
456 function slugify(str) {
457 return String(str)
458 .toLowerCase()
459 .replace(/[^a-z0-9]+/g, '-')
460 .replace(/^-|-$/g, '');
461 }
462
463 /**
464 * Build the vault-relative output path for a row.
465 * Convention: inbox/wallet-import/<YYYY-MM-DD>-<tx_hash_prefix>.md
466 * If no tx_hash, use date + amount + currency.
467 */
468 function buildOutputPath(outputBase, dateStr, txHash, amount, currency, rowIdx) {
469 const date = dateStr || new Date().toISOString().slice(0, 10);
470 let slug;
471 if (txHash) {
472 slug = String(txHash).replace(/^0x/i, '').slice(0, 12);
473 } else if (amount || currency) {
474 slug = slugify(`${amount || 'tx'}-${currency || 'unknown'}`);
475 } else {
476 slug = `row-${rowIdx}`;
477 }
478 const filename = `${date}-${slug}.md`;
479 return path.join(outputBase, 'wallet-import', filename).replace(/\\/g, '/');
480 }
481
482 // ---------------------------------------------------------------------------
483 // Note builder
484 // ---------------------------------------------------------------------------
485 function buildNote(fields, rowObj, extraTags, project) {
486 const {
487 txHash,
488 dateStr,
489 confirmedAt,
490 amount,
491 currency,
492 direction,
493 paymentStatus,
494 walletAddress,
495 network,
496 blockHeight,
497 } = fields;
498
499 const title = buildTitle(direction, amount, currency, network);
500 const tags = [...new Set(['payment', 'on-chain', ...extraTags])];
501 // Network-specific tag: e.g. icp → icp-tx, ethereum → eth-tx, solana → sol-tx
502 if (network === 'icp' || currency === 'ICP') {
503 tags.push('icp-tx');
504 } else if (network) {
505 const SHORT = {
506 ethereum: 'eth', bitcoin: 'btc', solana: 'sol', binance: 'bnb',
507 bsc: 'bnb', polygon: 'matic', avalanche: 'avax', polkadot: 'dot',
508 cardano: 'ada', kraken: null, coinbase: null, 'coinbase-pro': null, ledger: null,
509 };
510 const abbr = SHORT[network] !== undefined ? SHORT[network] : network;
511 if (abbr) tags.push(`${abbr}-tx`);
512 }
513
514 const frontmatter = {
515 title,
516 date: dateStr || new Date().toISOString().slice(0, 10),
517 source: 'wallet-csv-import',
518 source_id: txHash || buildFallbackSourceId(dateStr, amount, currency),
519 ...(network && { network }),
520 ...(walletAddress && { wallet_address: walletAddress }),
521 ...(txHash && { tx_hash: txHash }),
522 ...(paymentStatus && { payment_status: paymentStatus }),
523 ...(amount && { amount }),
524 ...(currency && { currency }),
525 ...(direction && { direction }),
526 ...(confirmedAt && { confirmed_at: confirmedAt }),
527 ...(blockHeight && { block_height: parseInt(blockHeight, 10) || blockHeight }),
528 tags,
529 ...(project && { project: normalizeSlug(project) }),
530 };
531
532 // Human-readable body
533 const blockStr = blockHeight ? `Block: ${Number(blockHeight).toLocaleString()} | ` : '';
534 const confirmedStr = confirmedAt
535 ? `Confirmed: ${confirmedAt.replace('T', ' ').replace('Z', ' UTC')}`
536 : '';
537 const body = [
538 'Transaction imported from wallet CSV export.',
539 `Amount: ${amount || '?'} ${currency || '?'} | Direction: ${direction || '?'} | Status: ${paymentStatus || '?'}`,
540 blockStr + confirmedStr,
541 ]
542 .filter(Boolean)
543 .join('\n');
544
545 return { frontmatter, body };
546 }
547
548 function buildTitle(direction, amount, currency, network) {
549 const parts = [network ? `${network.toUpperCase()} transfer` : 'Transaction'];
550 if (amount && currency) parts.push(`— ${amount} ${currency}`);
551 if (direction) parts.push(direction);
552 return parts.join(' ');
553 }
554
555 function buildFallbackSourceId(dateStr, amount, currency) {
556 return slugify(`${dateStr || 'unknown'}-${amount || 'tx'}-${currency || 'x'}`);
557 }
558
559 // ---------------------------------------------------------------------------
560 // Deduplication
561 // ---------------------------------------------------------------------------
562 function noteExists(vaultPath, outputRel) {
563 return fs.existsSync(path.join(vaultPath, outputRel));
564 }
565
566 // ---------------------------------------------------------------------------
567 // Main importer
568 // ---------------------------------------------------------------------------
569
570 /**
571 * @param {string} input - Path to wallet CSV file (or folder containing one .csv)
572 * @param {{
573 * vaultPath: string,
574 * outputBase: string,
575 * project?: string,
576 * tags: string[],
577 * dryRun: boolean,
578 * onProgress?: (p: { progress: number, total?: number, message?: string }) => void | Promise<void>
579 * }} ctx
580 * @returns {Promise<{ imported: { path: string, source_id?: string }[], count: number, skipped: number }>}
581 */
582 export async function importWalletCSV(input, ctx) {
583 const { vaultPath, outputBase, project, tags, dryRun, onProgress } = ctx;
584 const absInput = path.isAbsolute(input) ? input : path.resolve(process.cwd(), input);
585
586 if (!fs.existsSync(absInput)) {
587 throw new Error(`Input not found: ${input}`);
588 }
589
590 let csvPath = absInput;
591 if (fs.statSync(absInput).isDirectory()) {
592 const files = fs.readdirSync(absInput).filter((f) => f.toLowerCase().endsWith('.csv'));
593 if (files.length === 0) {
594 throw new Error('No .csv file found in folder. Export transactions from your wallet or exchange first.');
595 }
596 csvPath = path.join(absInput, files[0]);
597 } else if (!absInput.toLowerCase().endsWith('.csv')) {
598 throw new Error('wallet-csv import expects a .csv file (or folder containing one).');
599 }
600
601 const raw = fs.readFileSync(csvPath, 'utf8');
602 const { header, rows } = parseCSV(raw);
603
604 if (rows.length === 0) {
605 return { imported: [], count: 0, skipped: 0 };
606 }
607
608 // Detect named format and build normaliser
609 const formatNormalizer = detectFormat(header);
610
611 // Build column alias map for the generic path
612 const colMap = buildColumnMap(header);
613
614 const imported = [];
615 let skipped = 0;
616 const total = rows.length;
617
618 for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
619 const rawRow = rows[rowIdx];
620
621 // If we have a named format normaliser, merge its output into the row
622 // so the generic column map can still pick up fields the normaliser produces.
623 const row = formatNormalizer ? { ...rawRow, ...formatNormalizer(header, rawRow) } : rawRow;
624 // Rebuild colMap with potentially normalised keys for this row
625 const effectiveColMap = formatNormalizer ? buildColumnMap(Object.keys(row)) : colMap;
626
627 // Extract canonical fields
628 const txHashRaw = get(row, effectiveColMap, 'tx_hash');
629 const dateRaw = get(row, effectiveColMap, 'date');
630 const amountRaw = get(row, effectiveColMap, 'amount');
631 const currencyRaw = get(row, effectiveColMap, 'currency');
632 const directionRaw = get(row, effectiveColMap, 'direction');
633 const statusRaw = get(row, effectiveColMap, 'payment_status');
634 const walletRaw = get(row, effectiveColMap, 'wallet_address');
635 const networkRaw = get(row, effectiveColMap, 'network');
636 const blockRaw = get(row, effectiveColMap, 'block_height');
637
638 const confirmedAt = normalizeDate(dateRaw);
639 const dateStr = isoToDateStr(confirmedAt);
640 const txHash = txHashRaw || '';
641 const amount = amountRaw ? String(parseFloat(amountRaw) || amountRaw) : '';
642 const currency = currencyRaw.toUpperCase() || '';
643 const direction = DIRECTION_MAP[directionRaw.toLowerCase()] || (directionRaw ? directionRaw.toLowerCase() : '');
644 const paymentStatus = STATUS_MAP[statusRaw.toLowerCase()] || (statusRaw ? statusRaw.toLowerCase() : '');
645 const network = networkRaw.toLowerCase() || '';
646 const walletAddress = walletRaw || '';
647 const blockHeight = blockRaw || '';
648
649 const outputRel = buildOutputPath(
650 outputBase,
651 dateStr,
652 txHash,
653 amount,
654 currency,
655 rowIdx
656 );
657
658 // Deduplication: skip if a note with the same source_id already exists
659 if (!dryRun && noteExists(vaultPath, outputRel)) {
660 skipped++;
661 continue;
662 }
663
664 const fields = {
665 txHash,
666 dateStr,
667 confirmedAt,
668 amount,
669 currency,
670 direction,
671 paymentStatus,
672 walletAddress,
673 network,
674 blockHeight,
675 };
676
677 const { frontmatter, body } = buildNote(fields, row, tags, project);
678
679 // Clean frontmatter — remove empty strings and nulls
680 const cleanFrontmatter = Object.fromEntries(
681 Object.entries(frontmatter).filter(([, v]) => v !== undefined && v !== null && v !== '')
682 );
683
684 if (!dryRun) {
685 writeNote(vaultPath, outputRel, { body, frontmatter: cleanFrontmatter });
686 }
687
688 imported.push({ path: outputRel, source_id: cleanFrontmatter.source_id });
689
690 if (onProgress && total > 0) {
691 const n = rowIdx + 1;
692 const force = n === 1 || n === total || total <= 10;
693 if (force || n % 25 === 0) {
694 await onProgress({
695 progress: n,
696 total,
697 message: `wallet-csv import ${n}/${total}: ${path.basename(outputRel)}`,
698 });
699 }
700 }
701 }
702
703 return { imported, count: imported.length, skipped };
704 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago