|
| 1 | +/** |
| 2 | + * Airtable REST + mapping helpers for @reneza/ats-adapter-airtable. |
| 3 | + * |
| 4 | + * Mapping decision (see README): an Airtable **table** is an ATS Project and an |
| 5 | + * Airtable **record** is an ATS Task. projectId is the compound `baseId/tableId`; |
| 6 | + * taskId is the record id (`recXXXXXXXX`). The record's primary field becomes the |
| 7 | + * task title; the remaining fields are serialized into the markdown body so Core's |
| 8 | + * retrieval has text to match on. |
| 9 | + * |
| 10 | + * Zero runtime deps: uses global fetch (Node >= 18). |
| 11 | + */ |
| 12 | + |
| 13 | +import fs from 'node:fs'; |
| 14 | +import os from 'node:os'; |
| 15 | +import path from 'node:path'; |
| 16 | + |
| 17 | +const DEFAULT_ENDPOINT = 'https://api.airtable.com'; |
| 18 | +const PAGE_SIZE = 100; |
| 19 | + |
| 20 | +/** Per-process schema cache: baseId -> { tables, fetchedAt }. Tables rarely change within a run. */ |
| 21 | +const schemaCache = new Map(); |
| 22 | + |
| 23 | +/** Resolve config from env first, then ~/.config/ats/airtable.json. Env wins per key. */ |
| 24 | +export function loadConfig() { |
| 25 | + let file = {}; |
| 26 | + const p = configPath(); |
| 27 | + try { |
| 28 | + if (fs.existsSync(p)) file = JSON.parse(fs.readFileSync(p, 'utf8')) || {}; |
| 29 | + } catch { |
| 30 | + file = {}; |
| 31 | + } |
| 32 | + const basesEnv = process.env.ATS_AIRTABLE_BASES; |
| 33 | + const bases = |
| 34 | + (basesEnv ? basesEnv.split(',') : Array.isArray(file.bases) ? file.bases : []) |
| 35 | + .map((s) => String(s).trim()) |
| 36 | + .filter(Boolean); |
| 37 | + return { |
| 38 | + token: process.env.ATS_AIRTABLE_TOKEN || file.token || '', |
| 39 | + endpoint: process.env.ATS_AIRTABLE_ENDPOINT || file.endpoint || DEFAULT_ENDPOINT, |
| 40 | + bases, // optional allow-list of appXXX ids; empty = discover via Meta API |
| 41 | + defaultProject: |
| 42 | + process.env.ATS_AIRTABLE_DEFAULT_PROJECT || file.defaultProject || '', |
| 43 | + }; |
| 44 | +} |
| 45 | + |
| 46 | +export function configPath() { |
| 47 | + return ( |
| 48 | + process.env.ATS_AIRTABLE_CONFIG || |
| 49 | + path.join(os.homedir(), '.config', 'ats', 'airtable.json') |
| 50 | + ); |
| 51 | +} |
| 52 | + |
| 53 | +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 54 | + |
| 55 | +/** |
| 56 | + * Authenticated Airtable request with JSON parsing and 429 backoff. |
| 57 | + * @param {string} apiPath e.g. `/v0/meta/bases` |
| 58 | + * @param {{ method?: string, query?: Record<string,string|number|undefined>, body?: any, cfg?: object }} [opts] |
| 59 | + */ |
| 60 | +export async function air(apiPath, opts = {}) { |
| 61 | + const cfg = opts.cfg || loadConfig(); |
| 62 | + if (!cfg.token) throw new Error('Airtable: no token. Set ATS_AIRTABLE_TOKEN or write ~/.config/ats/airtable.json'); |
| 63 | + const url = new URL(cfg.endpoint.replace(/\/$/, '') + apiPath); |
| 64 | + for (const [k, v] of Object.entries(opts.query || {})) { |
| 65 | + if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v)); |
| 66 | + } |
| 67 | + const init = { |
| 68 | + method: opts.method || 'GET', |
| 69 | + headers: { Authorization: `Bearer ${cfg.token}` }, |
| 70 | + }; |
| 71 | + if (opts.body !== undefined) { |
| 72 | + init.headers['Content-Type'] = 'application/json'; |
| 73 | + init.body = JSON.stringify(opts.body); |
| 74 | + } |
| 75 | + |
| 76 | + // Airtable rate-limits at 5 req/sec/base -> 429 with Retry-After. Retry a few times. |
| 77 | + for (let attempt = 0; ; attempt++) { |
| 78 | + const res = await fetch(url, init); |
| 79 | + if (res.status === 429 && attempt < 4) { |
| 80 | + const wait = Number(res.headers.get('retry-after')) * 1000 || 1000 * (attempt + 1); |
| 81 | + await sleep(wait); |
| 82 | + continue; |
| 83 | + } |
| 84 | + const text = await res.text(); |
| 85 | + let json; |
| 86 | + try { |
| 87 | + json = text ? JSON.parse(text) : {}; |
| 88 | + } catch { |
| 89 | + json = { raw: text }; |
| 90 | + } |
| 91 | + if (!res.ok) { |
| 92 | + const msg = json?.error?.message || json?.error?.type || json?.error || text || res.statusText; |
| 93 | + throw new Error(`Airtable ${res.status} on ${apiPath}: ${typeof msg === 'string' ? msg : JSON.stringify(msg)}`); |
| 94 | + } |
| 95 | + return json; |
| 96 | + } |
| 97 | +} |
| 98 | + |
| 99 | +/** List bases the token can see (Meta API), honoring the optional allow-list. */ |
| 100 | +export async function listBases(cfg = loadConfig()) { |
| 101 | + if (cfg.bases.length) return cfg.bases.map((id) => ({ id, name: id })); |
| 102 | + const out = []; |
| 103 | + let offset; |
| 104 | + do { |
| 105 | + const page = await air('/v0/meta/bases', { query: { offset }, cfg }); |
| 106 | + for (const b of page.bases || []) out.push({ id: b.id, name: b.name || b.id }); |
| 107 | + offset = page.offset; |
| 108 | + } while (offset); |
| 109 | + return out; |
| 110 | +} |
| 111 | + |
| 112 | +/** Table schema for a base, cached per process. */ |
| 113 | +export async function listTables(baseId, cfg = loadConfig()) { |
| 114 | + const cached = schemaCache.get(baseId); |
| 115 | + if (cached) return cached; |
| 116 | + const page = await air(`/v0/meta/bases/${baseId}/tables`, { cfg }); |
| 117 | + const tables = page.tables || []; |
| 118 | + schemaCache.set(baseId, tables); |
| 119 | + return tables; |
| 120 | +} |
| 121 | + |
| 122 | +export function clearSchemaCache() { |
| 123 | + schemaCache.clear(); |
| 124 | +} |
| 125 | + |
| 126 | +/** Split a compound `baseId/tableId` projectId. */ |
| 127 | +export function splitProjectId(projectId) { |
| 128 | + const i = String(projectId).indexOf('/'); |
| 129 | + if (i < 0) throw new Error(`Airtable projectId must be "baseId/tableId", got "${projectId}"`); |
| 130 | + return { baseId: projectId.slice(0, i), tableId: projectId.slice(i + 1) }; |
| 131 | +} |
| 132 | + |
| 133 | +export function makeProjectId(baseId, tableId) { |
| 134 | + return `${baseId}/${tableId}`; |
| 135 | +} |
| 136 | + |
| 137 | +/** Render any Airtable cell value to a short string for title/body/search. */ |
| 138 | +export function stringifyCell(v) { |
| 139 | + if (v === undefined || v === null) return ''; |
| 140 | + if (typeof v === 'string') return v; |
| 141 | + if (typeof v === 'number' || typeof v === 'boolean') return String(v); |
| 142 | + if (Array.isArray(v)) return v.map(stringifyCell).filter(Boolean).join(', '); |
| 143 | + if (typeof v === 'object') { |
| 144 | + // collaborators, attachments, button, barcode, etc. |
| 145 | + return v.name || v.email || v.text || v.url || v.filename || v.label || JSON.stringify(v); |
| 146 | + } |
| 147 | + return String(v); |
| 148 | +} |
| 149 | + |
| 150 | +const isDateType = (t) => t === 'date' || t === 'dateTime'; |
| 151 | + |
| 152 | +/** Map a raw Airtable record + its table schema into an ATS Task. */ |
| 153 | +export function recordToTask(baseId, table, rec) { |
| 154 | + const fields = table.fields || []; |
| 155 | + const primary = fields.find((f) => f.id === table.primaryFieldId) || fields[0] || { name: 'Name' }; |
| 156 | + const title = stringifyCell(rec.fields?.[primary.name]) || '(untitled)'; |
| 157 | + |
| 158 | + const body = fields |
| 159 | + .filter((f) => f.id !== (primary.id ?? primary.name)) |
| 160 | + .map((f) => { |
| 161 | + const val = stringifyCell(rec.fields?.[f.name]); |
| 162 | + return val ? `**${f.name}:** ${val}` : ''; |
| 163 | + }) |
| 164 | + .filter(Boolean) |
| 165 | + .join('\n'); |
| 166 | + |
| 167 | + const tagsField = fields.find((f) => /^tags?$/i.test(f.name)); |
| 168 | + const tags = tagsField ? toArray(rec.fields?.[tagsField.name]) : []; |
| 169 | + |
| 170 | + const dueField = fields.find((f) => isDateType(f.type) && /(due|deadline|fällig)/i.test(f.name)); |
| 171 | + const dueRaw = dueField ? rec.fields?.[dueField.name] : undefined; |
| 172 | + |
| 173 | + const lastModField = fields.find((f) => f.type === 'lastModifiedTime'); |
| 174 | + const modifiedTime = |
| 175 | + (lastModField && rec.fields?.[lastModField.name]) || rec.createdTime || new Date(0).toISOString(); |
| 176 | + |
| 177 | + const task = { |
| 178 | + id: rec.id, |
| 179 | + title, |
| 180 | + content: body, |
| 181 | + projectId: makeProjectId(baseId, table.id), |
| 182 | + tags, |
| 183 | + modifiedTime, |
| 184 | + raw: rec, |
| 185 | + }; |
| 186 | + if (dueRaw) task.dueDate = new Date(dueRaw).toISOString(); |
| 187 | + return task; |
| 188 | +} |
| 189 | + |
| 190 | +function toArray(v) { |
| 191 | + if (v === undefined || v === null) return []; |
| 192 | + if (Array.isArray(v)) return v.map(stringifyCell).filter(Boolean); |
| 193 | + return String(v) |
| 194 | + .split(',') |
| 195 | + .map((s) => s.trim()) |
| 196 | + .filter(Boolean); |
| 197 | +} |
| 198 | + |
| 199 | +/** Page through every record of one table. */ |
| 200 | +export async function listRecords(baseId, tableId, cfg = loadConfig()) { |
| 201 | + const records = []; |
| 202 | + let offset; |
| 203 | + do { |
| 204 | + const page = await air(`/v0/${baseId}/${encodeURIComponent(tableId)}`, { |
| 205 | + query: { pageSize: PAGE_SIZE, offset }, |
| 206 | + cfg, |
| 207 | + }); |
| 208 | + for (const r of page.records || []) records.push(r); |
| 209 | + offset = page.offset; |
| 210 | + } while (offset); |
| 211 | + return records; |
| 212 | +} |
| 213 | + |
| 214 | +export async function getRecord(baseId, tableId, recordId, cfg = loadConfig()) { |
| 215 | + return air(`/v0/${baseId}/${encodeURIComponent(tableId)}/${recordId}`, { cfg }); |
| 216 | +} |
| 217 | + |
| 218 | +export async function createRecord(baseId, tableId, fields, cfg = loadConfig()) { |
| 219 | + return air(`/v0/${baseId}/${encodeURIComponent(tableId)}`, { |
| 220 | + method: 'POST', |
| 221 | + body: { fields, typecast: true }, |
| 222 | + cfg, |
| 223 | + }); |
| 224 | +} |
| 225 | + |
| 226 | +export async function updateRecord(baseId, tableId, recordId, fields, cfg = loadConfig()) { |
| 227 | + return air(`/v0/${baseId}/${encodeURIComponent(tableId)}/${recordId}`, { |
| 228 | + method: 'PATCH', |
| 229 | + body: { fields, typecast: true }, |
| 230 | + cfg, |
| 231 | + }); |
| 232 | +} |
| 233 | + |
| 234 | +/** |
| 235 | + * Build an Airtable fields payload from an ATS TaskInput/patch, given a table schema. |
| 236 | + * - title -> primary field |
| 237 | + * - content -> the first long-text (multilineText) field, or a field named Notes/Content |
| 238 | + * - tags -> a field named Tags (if present) |
| 239 | + */ |
| 240 | +export function taskInputToFields(table, input) { |
| 241 | + const fields = table.fields || []; |
| 242 | + const primary = fields.find((f) => f.id === table.primaryFieldId) || fields[0]; |
| 243 | + const out = {}; |
| 244 | + if (input.title !== undefined && primary) out[primary.name] = input.title; |
| 245 | + |
| 246 | + if (input.content !== undefined) { |
| 247 | + const contentField = |
| 248 | + fields.find((f) => f.type === 'multilineText' && f.id !== primary?.id) || |
| 249 | + fields.find((f) => /^(notes?|content|body|beschreibung)$/i.test(f.name) && f.id !== primary?.id); |
| 250 | + if (contentField) out[contentField.name] = input.content; |
| 251 | + } |
| 252 | + |
| 253 | + if (input.tags !== undefined) { |
| 254 | + const tagsField = fields.find((f) => /^tags?$/i.test(f.name)); |
| 255 | + if (tagsField) { |
| 256 | + out[tagsField.name] = |
| 257 | + tagsField.type === 'multipleSelects' ? input.tags : (input.tags || []).join(', '); |
| 258 | + } |
| 259 | + } |
| 260 | + return out; |
| 261 | +} |
| 262 | + |
| 263 | +export function urlForRecord(baseId, tableId, recordId) { |
| 264 | + return 'https://airtable.com/' + [baseId, tableId, recordId].filter(Boolean).join('/'); |
| 265 | +} |
0 commit comments