wallet-csv.mjs
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