Verizon Connect Work's own Comprehensive FAQ (updated 2026-10-19, re-read 2026-10-26) says not to export everything in one request:
Batching Best Practice: Request data in maximum 2-year intervals. Do not submit overlapping date ranges.
If you get "No Data Found" or blank CSVs, it says:
Break your request into smaller chronological chunks (e.g., 1-year or 6-month increments instead of the full 2-year limit)
On attachments:
Job attachments are packaged into separate monthly ZIP files to maintain server stability.
So one account ends up as a folder of CSVs and monthly ZIPs. If two ranges overlap, or you re-run a chunk, the same job shows up in more than one file. This post walks through how our job archive viewer combines those files in the browser, and gives a stdlib Python version you can run yourself.
One caveat up front: we haven't seen a real Work export. Everything below is tested against a synthetic fixture (tests/jobs/make-fixture.ts), not customer data.
1. Flatten the ZIPs first
Monthly ZIPs can sit inside a wrapper ZIP. lib/jobs/ingest.ts expands recursively and stops at depth 6:
async function expandBytes(data: Uint8Array, path: string, into: LeafFile[], depth: number): Promise<void> { if (isXlsxPath(path) || looksLikeXlsx(data)) { into.push({ path, data, ext: "xlsx" }); return; } const ext = fileExt(path); if (ext === "zip" || (isZipMagic(data) && ext !== "xlsx")) { await expandZip(data, path.replace(/\.zip$/i, ""), into, depth); return; } into.push({ path, data, ext }); } The xlsx check runs first because an .xlsx file is itself a ZIP. Each inner ZIP's name becomes a path prefix, so feb.zip → feb/jobs-later.csv.
2. Settle the date order before comparing anything
To decide which copy of a job is newer, you compare dates. If you compare 28/01/2025 and 15/02/2025 as strings, the January date sorts after the February one. lib/jobs/dates.ts first decides on one date order for every table:
/** * If any slash date has day>12 in the first position, use dd/mm. * If any has day>12 in the second position, use mm/dd. * When every slash date is 01–12 / 01–12, default to US mm/dd and mark ambiguous. */ export function sniffDateOrder(values: string[]): DateSniff { // ... const a = Number(m[1]); const b = Number(m[2]); if (a > 12 && b <= 12) dmy++; else if (b > 12 && a <= 12) mdy++; // ... if (dmy > mdy) return { order: "dmy", ambiguous: false }; if (mdy > dmy) return { order: "mdy", ambiguous: false }; // ... return { order: "mdy", ambiguous: true }; } The tests pin all three cases:
assert.deepEqual(sniffDateOrder(["25/01/2025", "03/02/2025"]), { order: "dmy", ambiguous: false }); assert.deepEqual(sniffDateOrder(["01/25/2025", "02/03/2025"]), { order: "mdy", ambiguous: false }); assert.deepEqual(sniffDateOrder(["03/02/2025", "01/04/2025"]), { order: "mdy", ambiguous: true }); process.ts runs the sniff over the date columns of every table together, rewrites the values to ISO YYYY-MM-DD, and adds a warning to the report when the order was ambiguous. After that, a plain string comparison is safe. That's all latestDate() does:
export function latestDate(row: TableRow): string { let best = ""; for (const [k, v] of Object.entries(row)) { if (!isDateField(k)) continue; if (v && v > best) best = v; } return best; } 3. Merge duplicates: the later row wins, nothing is dropped silently
buildJobs() in lib/jobs/model.ts keys rows by the job ID column. When a key repeats:
const newer = latestDate(row) >= latestDate(existing.fields); if (newer) { const extra = mergeRow(row, existing.fields); if (extra.length) conflicts.push(`Job ${key}: kept the later row; ${extra.slice(0, 4).join("; ")}`); existing.fields = { ...row, ...pickFilled(existing.fields, row) }; // ... } else { const extra = mergeRow(existing.fields, row); if (extra.length) conflicts.push(`Job ${key}: kept the later row; ${extra.slice(0, 4).join("; ")}`); // ... } if (!existing.sourcePaths.includes(table.sourcePath)) existing.sourcePaths.push(table.sourcePath); mergeRow() fills blank fields from the losing row and records every field where both rows have different values:
function mergeRow(into: TableRow, from: TableRow): string[] { const conflicts: string[] = []; for (const [k, v] of Object.entries(from)) { if (!v) continue; if (!into[k]) { into[k] = v; continue; } if (into[k] !== v) conflicts.push(`${k}: "${into[k]}" vs "${v}"`); } return conflicts; } Those strings end up in a "Duplicate conflicts" section of the generated report. The job also keeps the list of files it came from.
In the fixture, job 101 appears twice. In the main JSON it has Modified Date 28/01/2025 and notes Done. In feb.zip/jobs-later.csv it has 15/02/2025 and Updated in February export. The full-export test asserts the February row won:
const notes = job.fields["additional notes"] || ""; assert.match(notes, /February export/); 4. List what didn't match
Attachments that don't land in a job folder and don't match a job code aren't dropped. They're listed as unassigned with the note "no matching job folder or job code". The fixture's loose-photo.jpg sits inside a nested ZIP, and the test checks that it shows up there.
By hand, in Python
The same merge rule with only the standard library. Set the column names and the date format to match your files:
import csv, glob from datetime import datetime KEY, MODIFIED = "Job ID", "Modified Date" # use the column names in your files DATE_FORMAT = "%d/%m/%Y" # or "%m/%d/%Y": check a date with a day over 12 first def when(row): raw = (row.get(MODIFIED) or "").strip() return datetime.strptime(raw, DATE_FORMAT) if raw else datetime.min jobs = {} for path in sorted(glob.glob("exports/**/*.csv", recursive=True)): with open(path, newline="", encoding="utf-8-sig") as f: for row in csv.DictReader(f): key = (row.get(KEY) or "").strip() if not key: continue old = jobs.get(key) if old is None: jobs[key] = row continue new, older = (row, old) if when(row) >= when(old) else (old, row) for k, v in older.items(): if v and new.get(k) and new[k] != v: print(f"Job {key}: {k}: kept {new[k]!r}, dropped {v!r}") elif v and not new.get(k): new[k] = v jobs[key] = new print(len(jobs), "unique jobs") Run on two small CSVs shaped like the fixture's job 101, it prints:
Job 101: Modified Date: kept '15/02/2025', dropped '28/01/2025' Job 101: Additional Notes: kept 'Updated in February export', dropped 'Done' 2 unique jobs It only reads CSVs that are already unzipped. Our viewer also reads JSON and Excel, expands the nested ZIPs and matches attachments.
Our free browser tool does all four steps and builds a static archive you can keep: https://job-archive-viewer.pages.dev/?utm_source=devto&utm_medium=article&utm_campaign=vcw-merge-exports
Written with AI assistance from our own source code, tests and synthetic test fixture, and Verizon Connect's Comprehensive FAQ, and checked against them.