JavaScript reader and writer for Primavera P6 XER files. Pure ES modules, browser-compatible. The parser and writer core is dependency-free; the optional gzip helpers lazily import('pako'), which is the package's one declared runtime dependency.
Companion to cpp-cpm-engine and powers the CPP Lens browser viewer at https://criticalpathpartners.ca/viewer/.
v0.4.0 — calendars read the way P6 schedules on them: hours per day from the shifts when day_hr_cnt says otherwise, and an empty calendar record as P6's internal Standard; calendarShiftHours.
v0.3.2 — P6 XML parsing (parseP6Xml), pako gzip helpers, alias-aware table access, multi-name field fallback, opt-in rawText.
Install from the git repo. This works whether or not the package is on the npm registry:
npm install github:danafitkowski/cpp-lens-parsernpm resolves it under the name in package.json, so it lands at
node_modules/@criticalpathpartners/lens-parser and the bare
@criticalpathpartners/lens-parser specifier used in the examples below resolves
unchanged. There is no prepare step, and main points at src/index.js, so nothing
has to be built for Node use.
Pin a commit for a reproducible install:
npm install github:danafitkowski/cpp-lens-parser#a753a09Registry status: as of v0.4.0 (checked 2026-10-13) @criticalpathpartners/lens-parser
is not published to npm, so npm install @criticalpathpartners/lens-parser fails with
E404. Run npm view @criticalpathpartners/lens-parser to check the current state.
Two other paths, if a git dependency does not suit:
# Vendor it. src/ is plain ES modules with no build step, so the tree can be
# copied into a project and imported by relative path.
git clone https://github.com/danafitkowski/cpp-lens-parser.git
# Or build the single-file browser bundle (writes dist/index.js).
cd cpp-lens-parser && npm install && npm run buildimport {
parseXer, parseP6Xml, writeXer,
gzipText, gunzipText, gzipToBase64, gunzipFromBase64,
getTable, getFields,
getTableAliased, getFirstField, TABLE_ALIASES,
buildWbsMap, buildPredecessorMap, buildResourceMap, buildActivityCodeMap, buildUdfMap,
getCalendarMap, calendarShiftHours, addWorkDays, subtractWorkDays, getWorkDaysBetween,
durationHoursToDays
} from '@criticalpathpartners/lens-parser';
// Read an XER file
const xerText = await fetch('/schedule.xer').then(r => r.text());
const model = parseXer(xerText, { filename: 'schedule.xer' });
// Read a P6 XML export — same model shape as parseXer
const xmlText = await fetch('/schedule.xml').then(r => r.text());
const xmlModel = parseP6Xml(xmlText, { filename: 'schedule.xml' });
// gzip helpers (pako) for compact storage/transport of XER text.
// All four gzip helpers are async (pako is imported lazily), so await them.
const packed = await gzipToBase64(xerText); // base64 of gzipped bytes
const original = await gunzipFromBase64(packed); // round-trips back to xerText
// Access tables — canonical name
const tasks = getTable(model, 'TASK');
const taskFields = getFields(model, 'TASK');
// Access tables — alias-aware (works with non-canonical P6 export variants)
const wbsRecords = getTableAliased(model, 'WBS'); // resolves to PROJWBS
const preds = getTableAliased(model, 'REL'); // resolves to TASKPRED
const assigns = getTableAliased(model, 'ASSIGN'); // resolves to TASKRSRC
// Normalize field-name variants across P6 export versions
const taskId = getFirstField(tasks[0], ['task_id', 'task_code']);
const wbsName = getFirstField(wbsRecords[0], ['wbs_name', 'wbs_short_name']);
// Build derived lookups
const wbsMap = buildWbsMap(model); // wbs_id -> wbs record with _full_path
const { predecessors, successors } = buildPredecessorMap(model);
const calendars = getCalendarMap(model); // clndr_id -> parsed calendar info
const shifted = calendarShiftHours(calendars); // calendars converted on their shifts
// Calendar arithmetic
const finish = addWorkDays('2024-01-15', 10, calendars['1']); // adds 10 work days
const workDays = getWorkDaysBetween('2024-01-15', '2024-02-15', calendars['1']);
// Edit the model
tasks[0].task_name = 'Renamed activity';
// Write back to XER text
const updatedXer = writeXer(model);parseXer returns the canonical Python parser shape:
{
ermhdr: {
raw, version, export_date, user, database, currency
},
tables: {
TABLE_NAME: {
fields: ['field1', 'field2', ...],
records: [{ field1: '<string>', field2: '<string>', ... }, ...]
}
},
filepath, filename, parse_timestamp, encoding_used
}Every record value is a string — no type coercion at parse time. Type-aware operations live in derived helpers (buildWbsMap, calendar arithmetic, etc.) that work on the raw string data.
model.ermhdr.isHalfStep is reserved for use by callers who know the file was produced by a Half-Step generation (AACE 29R-03 MIP 3.4). The parser does NOT auto-detect — Half-Step is a property of generation provenance, not file contents. The generator function itself is provided by the companion Python skill.
Every fixture in tests/fixtures/ is parsed by both this library and the canonical Python xer-parser skill; the resulting models are diffed and any divergence fails the run:
npm run test:parityThis one suite needs a local copy of that Python skill (tests/parity/python_emit.py
imports it from ~/.claude/skills/xer-parser/scripts). It is not vendored here, because
a checked-in copy would drift from the canonical source and fake parity against a stale
reference. Without it every parity test errors at import, so the suite is deliberately
excluded from CI rather than left to fail or skip silently. A clone without the skill
should run the CI invocation instead:
npx vitest run --exclude "tests/parity/**"Round-trip writer fidelity is verified separately and does run in CI: every fixture
passes parseXer(writeXer(parseXer(text))) byte-identical at the ermhdr + tables
level.
Calendars are read the way P6 schedules on them. Each rule was measured on Primavera P6 Professional 23.12.1's own F9 and is the one cpp-xer-parser v0.4.0 and cpp-cpm-engine v2.10.1 apply. getCalendarMap can give a calendar a different hours_per_day, and an empty record its working days, so durationHoursToDays and anything that reads the map's hours_per_day or work_days directly can move. The working-day arithmetic (getWorkDaysBetween, addWorkDays, subtractWorkDays) already fell back to Monday to Friday for an empty record and counts as before.
- Hours per day come from the shifts when
day_hr_cntdisagrees with all of them. P6 schedules a duration in hours against the calendar's shifts;day_hr_cnt("Hours per Day") only converts hours to days for display. A Monday-Saturday 07:00-17:00 calendar declaring 8 ran a 40 h activity over four days;durationHoursToDaysgave five and now gives four. When every working day of the week works the same hours H and H is notday_hr_cnt,hours_per_dayis H and the file's figure is kept asdeclared_hours_per_day. A week whose working days work different hours, or an older bare-day encoding that carries no hours, keepsday_hr_cnt. Slots count in either field order, and a 00:00 finish is the end of the day. - An empty calendar record is P6's internal Standard. A record with no
clndr_datais scheduled Monday to Friday, 08:00-16:00, eight hours a day, with no holidays, whateverday_hr_cntdeclares: records declaring 10, 6 and 24 hours ran the same days. v0.3.2 gave such a record an emptywork_dayslist (the arithmetic then fell back to Monday to Friday) and converted hours at the declared figure, so 40 hours on a record declaring 10 read as four days where P6 schedules five. The entry carriesempty_record: trueand keeps the declared figure asdeclared_hours_per_day. calendarShiftHours(calMap)lists the calendars whosehours_per_daywas taken from their shifts:clndr_id,clndr_name,hours_per_day,declared_hours_per_day. An empty record is not listed: it has no shifts.
parseP6Xml(text, opts)— parses a Primavera P6 XML export into the SAME model shapeparseXerreturns ({ ermhdr, tables, filepath, ... }), with XML elements mapped to XER table names (Activity→TASK,Relationship→TASKPRED,WBS→PROJWBS, etc.) and enum values translated to XER codes. Downstream helpers (getTable,buildPredecessorMap, the Lens viewer renderers) work on XML inputs with no source changes. Unmapped fields pass through (never-truncate rule), andStart/Finishcolumns are pinned overEarly/Plannedon field collisions.- pako gzip helpers —
gzipText/gunzipTextandgzipToBase64/gunzipFromBase64for compact storage and transport of XER text (the only runtime dependency,pako, backs these). - Overflow-cell preservation — rows with more cells than declared fields keep the extras under
__extra_Nkeys rather than dropping them. parse_incompleteflag — calendars whoseclndr_datacannot be fully decoded are flagged rather than silently mis-parsed; the exception-date window was widened to 1970-2099.- Finish-first time slots — P6 writes a calendar time slot start-first
(s|08:00|f|16:00)or finish-first(f|12:00|s|08:00), per calendar. Both now decode, in the work week and in exceptions, as the canonical Pythonxer-parserhas since 2026-08-25. Before this, a finish-first calendar decoded to no work days (parse_incomplete, then Mon-Fri downstream) and its working exceptions were read as holidays. - Absurd-span guard —
getWorkDaysBetweenreturnsnullon a span over ~100 years instead of looping, guarding against malformed dates.
getTableAliased(model, alias)— resolves logical names (WBS,REL,ASSIGN,CAL,COST, etc.) to their canonical P6 table names via the exportedTABLE_ALIASESmap. Falls back to a direct lookup when the name isn't in the map, so it's a safe drop-in forgetTable.getFirstField(record, keys)— returns the first non-empty value from a record across an ordered list of candidate key names. Handles thetask_id/task_code,wbs_name/wbs_short_name,pred_type/rel_typevariants common across P6 export generations. Null and empty-string are treated as missing; numeric zero and'0'are real values.parseXer(text, { keepRawText: true })— opt-in flag that stores the original input text onmodel.rawText. Off by default (XERs can be large). Needed by utilities like POBS Cleaner that re-emit a modified version of the original bytes.
npm test runs 333 tests across 31 files, all passing. Its pretest hook generates the
5000-activity perf fixture and builds dist/ first; both are gitignored, so a bare
npx vitest run on a fresh clone collects 331 tests and fails 6 of them purely for those
missing artefacts. Run npm test, not npx vitest run.
CI runs 323 of the 333, across 30 files
(npx vitest run --exclude "tests/parity/**"), excluding parity for the reason above.
The 333 are 9 JS↔Python parity comparisons plus a fixture-set pin (10), 9 writer round-trip cases plus 7 other writer tests (16), 5 writer TSV-safety tests, 4 Web Worker tests, 3 bundle smoke tests, 1 perf test, and 294 unit tests across the parser, XML reader, access helpers, calendars and encoding.
MIT. See LICENSE.