import-tracker.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* import-tracker.mjs — bring a job tracker exported from another tool into
* applications.md.
*
* Reads a CSV export (Teal, Huntr, Simplify, or a hand-kept spreadsheet), maps
* its columns and stage names onto the tracker's, and adds one row per
* application through the normal merge: a reserved number, a headed
* tracker-addition TSV with the `N/A` score sentinel, then merge-tracker.mjs.
* Rows the tracker already holds (same posting URL, or same company and role)
* are skipped, so running it twice adds nothing the second time. Stages that
* mean "not applied yet" (Bookmarked, Wishlist, Saved) are left out: in
* career-ops those postings belong in the pipeline, where they get evaluated.
*
* Dry run by default. Nothing is written unless `--confirm` is passed.
*
* Usage:
* node import-tracker.mjs <export.csv> # preview
* node import-tracker.mjs <export.csv> --confirm # add the rows
* node import-tracker.mjs <export.csv> --day-first # read 03/04/2026 as 3 April
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'fs';
import { basename, dirname, isAbsolute, join, resolve } from 'path';
import { tmpdir } from 'os';
import { fileURLToPath } from 'url';
import { spawnSync } from 'child_process';
import { parseCsv } from './linkedin-join.mjs';
import { resolveColumns, parseTrackerRow, normalizeTextKey } from './tracker-parse.mjs';
import { loadCanonicalStates, resolveCanonicalState } from './tracker-utils.mjs';
import { normalizeUrl } from './url-key.mjs';
import { getCareerOpsRoot, resolveTrackerPath, resolveTrackerPathForWrite } from './path-resolver.mjs';
import { reserveReportNumbers, releaseReportNumbers } from './reserve-report-num.mjs';
import { tsvSafe, slugifyCompany, isPostingUrl } from './lib/tracker-addition.mjs';
import { hasFlag, validateFlags } from './lib/cli-flags.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
import { localToday } from './lib/local-today.mjs';
const CODE_ROOT = dirname(fileURLToPath(import.meta.url));
const STATES_FILE = join(CODE_ROOT, 'templates/states.yml');
const RESERVE_CHUNK = 50;
const PREVIEW_ROWS = 20;
const usage = () => `Usage: node import-tracker.mjs <export.csv> [options]
Adds the applications in a CSV export from another tracker (Teal, Huntr,
Simplify, a spreadsheet) to data/applications.md. Dry run by default.
--confirm Add the rows (default is a preview that writes nothing)
--day-first Read slash dates as day/month/year (default: inferred from
the file, month/day/year when every date fits both)
-h, --help Show this help and exit
The file needs a company column and a role column. Recognized headers:
${Object.entries(COLUMN_ALIASES).map(([f, a]) => ` ${f.padEnd(9)} ${a.join(', ')}`).join('\n')}`;
// Header names other trackers use, per tracker field. Earlier names win when a
// file has several, so "Date Applied" beats Huntr's "Created At".
export const COLUMN_ALIASES = {
company: ['company', 'company name', 'employer', 'organization', 'organisation'],
role: ['role', 'job title', 'title', 'position', 'job position', 'position title', 'job'],
status: ['status', 'stage', 'list', 'application status', 'job status'],
date: ['date applied', 'applied date', 'applied on', 'application date', 'applied', 'date', 'created at', 'date added', 'date saved'],
url: ['url', 'job url', 'job link', 'posting link', 'posting url', 'link', 'apply url'],
location: ['location', 'job location'],
notes: ['notes', 'note', 'comments'],
};
const NOT_APPLIED = 'not-applied';
// Stage names from other trackers that states.yml does not already resolve.
// Teal: Bookmarked, Applying, Interviewing, Negotiating, Not Selected,
// No Response, Withdrawn, Archived. Huntr: Wishlist. Simplify: Saved, Screen.
export const OTHER_TRACKER_STAGES = {
bookmarked: NOT_APPLIED,
wishlist: NOT_APPLIED,
saved: NOT_APPLIED,
interested: NOT_APPLIED,
applying: NOT_APPLIED,
'to apply': NOT_APPLIED,
'not applied': NOT_APPLIED,
submitted: 'Applied',
screen: 'Responded',
'phone screen': 'Responded',
'recruiter screen': 'Responded',
interviewing: 'Interview',
'final round': 'Interview',
negotiating: 'Offer',
'offer received': 'Offer',
'not selected': 'Rejected',
'no response': 'Discarded',
ghosted: 'Discarded',
withdrawn: 'Discarded',
archived: 'Discarded',
};
// What happens to each source row, in the order the preview lists them.
export const OUTCOMES = {
import: 'to import',
tracked: 'already in the tracker',
repeat: 'repeated in this file',
'not-applied': 'not applied yet (add these to the pipeline to evaluate them)',
'unknown-status': 'unknown stage (rename it in the file to a tracker state)',
incomplete: 'missing company or role',
};
const headerKey = (h) => String(h ?? '').replace(/^\uFEFF/, '').trim().toLowerCase().replace(/\s+/g, ' ');
/**
* Map tracker fields to column indexes by header name.
* @param {string[]} header
* @returns {{columns: Record<string, number>, ignored: string[]}}
*/
export function mapColumns(header) {
const keys = header.map(headerKey);
const columns = {};
for (const [field, aliases] of Object.entries(COLUMN_ALIASES)) {
for (const alias of aliases) {
const at = keys.indexOf(alias);
if (at !== -1 && !Object.values(columns).includes(at)) { columns[field] = at; break; }
}
}
const used = new Set(Object.values(columns));
return { columns, ignored: header.filter((h, i) => !used.has(i) && headerKey(h)) };
}
const MONTHS = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 };
function isoDate(y, m, d) {
const iso = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const date = new Date(`${iso}T00:00:00Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === iso ? iso : null;
}
const SLASH_DATE = /^(\d{1,2})[/.](\d{1,2})[/.](\d{4})(?:[ T,].*)?$/;
/**
* Read a date the way trackers and spreadsheets export it.
* @param {string} raw - "2026-07-03", "2026-07-03T14:00:00Z", "7/3/2026", "Jul 3, 2026", "3 Jul 2026".
* @param {boolean} dayFirst - How to read a slash date.
* @returns {string|null} YYYY-MM-DD, or null when the value is empty or unreadable.
*/
export function readSourceDate(raw, dayFirst) {
const s = String(raw ?? '').trim();
let m = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})(?:[ T].*)?$/.exec(s);
if (m) return isoDate(m[1], +m[2], +m[3]);
m = SLASH_DATE.exec(s);
if (m) return dayFirst ? isoDate(m[3], +m[2], +m[1]) : isoDate(m[3], +m[1], +m[2]);
m = /^(\d{1,2})[ -]([A-Za-z]{3,})\.?[ ,-]+(\d{4})$/.exec(s);
if (m && MONTHS[m[2].slice(0, 3).toLowerCase()]) return isoDate(m[3], MONTHS[m[2].slice(0, 3).toLowerCase()], +m[1]);
m = /^([A-Za-z]{3,})\.? (\d{1,2}),? (\d{4})$/.exec(s);
if (m && MONTHS[m[1].slice(0, 3).toLowerCase()]) return isoDate(m[3], MONTHS[m[1].slice(0, 3).toLowerCase()], +m[2]);
return null;
}
/**
* Decide how to read the file's slash dates from the dates themselves.
* A first part above 12 proves day/month, a second part above 12 proves
* month/day. A file with both is refused rather than half misread.
* @param {string[]} values
* @returns {boolean} true for day/month/year.
*/
export function inferDayFirst(values) {
let dayFirst = false;
let monthFirst = false;
for (const v of values) {
const m = SLASH_DATE.exec(String(v ?? '').trim());
if (!m) continue;
if (+m[1] > 12) dayFirst = true;
if (+m[2] > 12) monthFirst = true;
}
if (dayFirst && monthFirst) {
throw new Error('The date column mixes day/month/year and month/day/year dates. Make them one format and run again.');
}
return dayFirst;
}
/**
* Resolve a source stage to a tracker state.
* @returns {string|null} A canonical label, NOT_APPLIED, or null when unknown.
*/
export function resolveStage(raw, states) {
const s = String(raw ?? '').trim();
if (!s) return 'Applied';
const key = headerKey(s);
return resolveCanonicalState(s, states)
?? (Object.hasOwn(OTHER_TRACKER_STAGES, key) ? OTHER_TRACKER_STAGES[key] : null);
}
const roleKey = (company, role) => `${normalizeTextKey(company)}\u0000${normalizeTextKey(role)}`;
/**
* Plan an import without touching disk.
* @param {string[][]} csvRows - parseCsv() output, header first.
* @param {{states: object[], trackerRows: object[], today: string, dayFirst?: boolean, source: string}} ctx
*/
export function planImport(csvRows, { states, trackerRows, today, dayFirst, source }) {
// `row` is the spreadsheet row number, so blank rows still count.
const rows = csvRows.map((cells, i) => ({ cells, row: i + 1 })).filter((r) => r.cells.some((c) => c.trim()));
if (!rows.length) throw new Error('The file is empty.');
const header = rows[0].cells;
const body = rows.slice(1);
const { columns, ignored } = mapColumns(header);
const missing = ['company', 'role'].filter((f) => columns[f] == null);
if (missing.length) {
throw new Error(`No ${missing.join(' or ')} column found. The file's headers are: ${header.map((h) => `"${h.trim()}"`).join(', ')}. Rename one to a recognized header (see --help).`);
}
const at = (r, f) => (columns[f] == null ? '' : String(r.cells[columns[f]] ?? '').trim());
const dates = body.map((r) => at(r, 'date'));
const readDayFirst = dayFirst ?? inferDayFirst(dates);
// One posting is one key: the URL when both sides have one, otherwise the
// company and role. Two different URLs under one title are two postings.
const index = () => ({ byUrl: new Map(), byRole: new Map() });
const remember = (idx, urlKey, rk, value) => {
if (urlKey) idx.byUrl.set(urlKey, value);
idx.byRole.set(rk, [...(idx.byRole.get(rk) ?? []), { urlKey, value }]);
};
const lookup = (idx, urlKey, rk) => (urlKey ? idx.byUrl.get(urlKey) : undefined)
?? (idx.byRole.get(rk) ?? []).find((c) => !(c.urlKey && urlKey && c.urlKey !== urlKey))?.value;
const tracked = index();
for (const t of trackerRows) remember(tracked, normalizeUrl(t.url), roleKey(t.company, t.role), t.num);
const seen = index();
const entries = body.map((r) => {
const company = at(r, 'company');
const role = at(r, 'role');
const sourceStatus = at(r, 'status');
const rawUrl = at(r, 'url');
const url = isPostingUrl(rawUrl) ? rawUrl : '';
const base = { row: r.row, company, role, sourceStatus, url, location: at(r, 'location'), sourceNotes: at(r, 'notes') };
if (!company || !role) return { ...base, outcome: 'incomplete' };
const status = resolveStage(sourceStatus, states);
if (status === NOT_APPLIED) return { ...base, outcome: 'not-applied' };
if (!status) return { ...base, outcome: 'unknown-status' };
const urlKey = normalizeUrl(url);
const rk = roleKey(company, role);
const trackedNum = lookup(tracked, urlKey, rk);
if (trackedNum != null) return { ...base, status, outcome: 'tracked', trackedNum };
const firstRow = lookup(seen, urlKey, rk);
if (firstRow != null) return { ...base, status, outcome: 'repeat', firstRow };
remember(seen, urlKey, rk, r.row);
const date = readSourceDate(at(r, 'date'), readDayFirst);
return { ...base, status, date: date ?? today, undated: !date, outcome: 'import' };
});
const notes = (e) => [
`imported from ${source}`,
e.sourceStatus && e.sourceStatus.toLowerCase() !== e.status.toLowerCase() ? `source stage: ${e.sourceStatus}` : '',
e.undated ? 'no date in source' : '',
e.sourceNotes,
].filter(Boolean).join('; ');
for (const e of entries) if (e.outcome === 'import') e.notes = notes(e);
return { columns: Object.fromEntries(Object.entries(columns).map(([f, i]) => [f, header[i].trim()])), ignored, dayFirst: readDayFirst, slashDates: dates.some((d) => SLASH_DATE.test(d)), entries };
}
/** Headed tracker-addition TSV for one imported row. */
export function additionTsv(entry, num, { locationColumn, urlColumn }) {
const labels = ['num', 'date', 'company', 'role', 'status', 'score', 'pdf', 'report', 'notes'];
const notes = !locationColumn && entry.location ? `${entry.notes}; ${entry.location}` : entry.notes;
const fields = [num, entry.date, entry.company, entry.role, entry.status, 'N/A', '❌', '—', notes];
if (locationColumn && entry.location) { labels.push('location'); fields.push(entry.location); }
if (urlColumn && entry.url) { labels.push('url'); fields.push(entry.url); }
return `${labels.join('\t')}\n${fields.map(tsvSafe).join('\t')}\n`;
}
function readTracker(trackerPath) {
if (!existsSync(trackerPath)) return { exists: false, rows: [], colmap: null };
const lines = readFileSync(trackerPath, 'utf-8').split(/\r?\n/);
const colmap = resolveColumns(lines);
return { exists: true, colmap, rows: lines.map((l) => parseTrackerRow(l, colmap)).filter(Boolean) };
}
function printPreview(plan, { source, confirm, urlColumn, trackerExists, blocked = false }) {
const count = (o) => plan.entries.filter((e) => e.outcome === o);
const toImport = count('import');
console.log(confirm && toImport.length ? `Importing ${source}` : `Import preview for ${source} (nothing written)`);
console.log(`Columns: ${Object.entries(plan.columns).map(([f, h]) => `${f} <- "${h}"`).join(', ')}`);
if (plan.ignored.length) console.log(`Ignored columns: ${plan.ignored.map((h) => h.trim()).join(', ')}`);
console.log('');
for (const [outcome, label] of Object.entries(OUTCOMES)) {
const rows = count(outcome);
if (!rows.length) continue;
console.log(`${String(rows.length).padStart(4)} ${label}`);
const shown = outcome === 'import' ? rows.slice(0, PREVIEW_ROWS) : rows.slice(0, 5);
for (const e of shown) {
const detail = {
import: `${e.date} ${e.status}`,
tracked: `#${e.trackedNum}`,
repeat: `same as row ${e.firstRow}`,
'not-applied': e.sourceStatus,
'unknown-status': `"${e.sourceStatus}"`,
incomplete: '',
}[outcome];
console.log(` row ${e.row}: ${e.company || '?'} | ${e.role || '?'}${detail ? ` (${detail})` : ''}`);
}
if (rows.length > shown.length) console.log(` ... ${rows.length - shown.length} more`);
}
const undated = toImport.filter((e) => e.undated).length;
if (undated) console.log(`\n${undated} row(s) have no readable date and get today's date, with "no date in source" in Notes.`);
if (plan.slashDates) console.log(`\nSlash dates are read as ${plan.dayFirst ? 'day/month/year' : 'month/day/year'}.${plan.dayFirst ? '' : ' Pass --day-first if they are day/month/year.'}`);
const links = toImport.filter((e) => e.url).length;
if (links && !urlColumn) {
console.log(`\n${links} row(s) carry a posting link, and the tracker has no URL column, so the links will not be kept. Run "node merge-tracker.mjs --backfill-urls" once to add the column, then import.`);
}
if (!trackerExists && toImport.length) console.log('\nNo tracker yet: --confirm creates data/applications.md.');
if (!confirm && !blocked && toImport.length) console.log(`\nRun again with --confirm to add ${toImport.length} row(s).`);
}
const TRACKER_HEADER = ['#', 'Date', 'Company', 'Role', 'Score', 'Status', 'PDF', 'Report', 'Notes'];
function createTracker(path, withUrl) {
const cols = withUrl ? [...TRACKER_HEADER, 'URL'] : TRACKER_HEADER;
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `# Applications Tracker\n\n| ${cols.join(' | ')} |\n|${cols.map(() => '---').join('|')}|\n`, { flag: 'wx' });
}
// The re-run must reach the same tracker and batch state as this import, from any
// working directory, so it carries the path overrides and the script's full path.
// The root overrides already resolve against the code root, but the tracker and
// batch-state overrides resolve against the working directory, so those two are
// made absolute here.
const RETRY_ENV = ['CAREER_OPS_ROOT', 'CAREER_OPS_DATA_DIR', 'CAREER_OPS_TRACKER', 'CAREER_OPS_BATCH_STATE'];
const RETRY_CWD_PATHS = new Set(['CAREER_OPS_TRACKER', 'CAREER_OPS_BATCH_STATE']);
const shellQuote = (value) => (/^[A-Za-z0-9._@+/:=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`);
function retryCommand(additionsDir, env = process.env) {
const overrides = RETRY_ENV
.filter((name) => env[name]?.trim())
.map((name) => {
const value = env[name].trim();
return `${name}=${shellQuote(RETRY_CWD_PATHS.has(name) ? resolve(value) : value)} `;
})
.join('');
return `${overrides}CAREER_OPS_ADDITIONS=${shellQuote(additionsDir)} node ${shellQuote(join(CODE_ROOT, 'merge-tracker.mjs'))}`;
}
async function writeImport(entries, { colmap }) {
// Private directory: --confirm must not merge or delete TSVs that were already waiting,
// and CAREER_OPS_ADDITIONS must not point merge at a different directory than these files.
const additionsDir = mkdtempSync(join(tmpdir(), 'import-tracker-'));
const shape = { locationColumn: colmap?.location != null, urlColumn: colmap?.url != null };
const reservations = [];
let leftNums = null;
try {
for (let i = 0; i < entries.length; i += RESERVE_CHUNK) {
const chunk = entries.slice(i, i + RESERVE_CHUNK);
const nums = await reserveReportNumbers(chunk.length);
reservations.push(nums);
chunk.forEach((entry, j) => {
const file = join(additionsDir, `${nums[j]}-${slugifyCompany(entry.company)}.tsv`);
writeFileSync(file, additionTsv(entry, nums[j], shape), { flag: 'wx' });
});
}
const merge = spawnSync(process.execPath, [join(CODE_ROOT, 'merge-tracker.mjs')], {
stdio: 'inherit',
env: { ...process.env, CAREER_OPS_ADDITIONS: additionsDir },
});
// merge-tracker leaves a TSV it refused (or could not apply) where it was,
// and a refusal alone still exits 0. Those rows are not in the tracker, so
// their files and numbers stay for a re-run instead of being deleted here.
const left = readdirSync(additionsDir).filter((f) => f.endsWith('.tsv'));
if (left.length) {
leftNums = new Set(left.map((f) => Number(/^(\d+)-/.exec(f)?.[1])));
console.error(`\nimport-tracker: ${left.length} row(s) were not added: ${left.join(', ')}.`);
console.error(`Their files stay in ${additionsDir} with their numbers reserved. Fix each as the warning above says, then run:`);
console.error(` ${retryCommand(additionsDir)}`);
return 1;
}
return merge.status ?? 1;
} finally {
if (!leftNums) rmSync(additionsDir, { recursive: true, force: true });
for (const nums of reservations) {
if (leftNums && nums.some((n) => leftNums.has(n))) continue;
await releaseReportNumbers(nums);
}
}
}
if (isMainModule(import.meta.url)) {
const args = process.argv.slice(2);
validateFlags(args, ['--confirm', '--day-first', '--help', '-h'], usage(), { requireOperand: true });
const fail = (msg) => { console.error(`import-tracker: ${msg}`); process.exit(1); };
const named = args.find((a) => !a.startsWith('-'));
if (!named) fail('name the CSV file to import. See --help.');
const dataRoot = getCareerOpsRoot();
const file = isAbsolute(named) ? named : join(dataRoot, named);
if (!existsSync(file)) fail(`no such file: ${file}`);
const confirm = hasFlag(args, '--confirm');
let trackerPath = resolveTrackerPath(dataRoot);
let tracker = readTracker(trackerPath);
if (!tracker.exists) trackerPath = resolveTrackerPathForWrite(dataRoot);
let plan;
try {
plan = planImport(parseCsv(readFileSync(file, 'utf-8')), {
states: loadCanonicalStates(STATES_FILE),
trackerRows: tracker.rows,
today: localToday(),
dayFirst: hasFlag(args, '--day-first') ? true : undefined,
source: basename(file),
});
} catch (err) {
fail(err.message);
}
const toImport = plan.entries.filter((e) => e.outcome === 'import');
const withUrl = toImport.some((e) => e.url);
const urlColumn = tracker.exists ? tracker.colmap?.url != null : withUrl;
// Without a URL column, two postings for the same company and role differ
// only by the link the TSV cannot carry, and merge's company-and-role tier
// would fold the second into the first. Refuse instead of losing a row.
const blocked = confirm && withUrl && !urlColumn;
printPreview(plan, { source: basename(file), confirm: confirm && !blocked, urlColumn, trackerExists: tracker.exists, blocked });
if (blocked) fail('the tracker has no URL column, so --confirm would drop the posting links. Run "node merge-tracker.mjs --backfill-urls" once to add it, then import.');
if (!confirm || !toImport.length) process.exit(0);
if (!tracker.exists) {
createTracker(trackerPath, withUrl);
tracker = readTracker(trackerPath);
}
console.log('');
process.exit(await writeImport(toImport, { colmap: tracker.colmap }));
}