💡 Full working example available on GitHub: office-metadata-pii-cleanup-nodejs
Introduction
An upload endpoint accepts a DOCX from a staff member and stores it against a customer ticket. The text is fine. The properties are not: the file names the person who drafted it, the colleague who last saved it, the department manager from the corporate template, and, because it came off SharePoint, the approver who signed it off.
A metadata sanitizer is a small script that deletes those properties before the file is stored and then checks its own work. This tutorial builds one in Node.js with GroupDocs.Metadata, in four steps: select properties by tag, select them by name, wipe everything when selectivity stops helping, and verify what is left. Each step is a few lines, and the finished script is under a hundred.
Why Metadata Sanitization Matters
The data accumulates without anyone choosing it. Word writes Author and LastSavedBy from the operating-system account on every save, keeps a revision counter, tracks TotalEditingTime, and records LastPrinted. Document servers add workflow paths, approver identifiers, and content-type URIs on check-in. None of this appears when the document is read or printed, so a proofread never catches it.
The point of doing it in Node.js rather than by hand is that a script returns numbers: each removal call reports how many properties it deleted, and that count can be written to a log, asserted in a test, or attached to the record the document belongs to.
There is a second reason, less obvious until a batch job is running. Manual cleanup is a decision made once per file by whoever happens to be handling it, so two people sanitizing the same kind of document produce different results. A script fixes the rule in one place: the same four tags, the same substring lists, applied identically whether the queue holds three files or three thousand.
Prerequisites
The package is Node.js via Java, so the machine needs a Java runtime alongside Node.
Installation
npm install @groupdocs/groupdocs.metadata
The sample project pins version 26.7 and adds an overrides entry setting nan to ^2.22.0, which keeps the native binding building on current Node releases. Without a license file the library runs in evaluation mode, which is enough to follow every step here.
Step 1 - Select properties by what they mean
Property names differ between formats and packages, so the first rule matches on tags instead. ContainsTagSpecification takes a tag and matches any property carrying it; .or() merges specifications into one.
const T = groupdocs.Tags;
const spec = new groupdocs.ContainsTagSpecification(T.getPerson().getCreator())
.or(new groupdocs.ContainsTagSpecification(T.getPerson().getEditor()))
.or(new groupdocs.ContainsTagSpecification(T.getPerson().getManager()))
.or(new groupdocs.ContainsTagSpecification(T.getCorporate().getCompany()));
const affected = metadata.removeProperties(spec);
metadata.save(outputPath);
Key points:
- Four tags cover the identity group: creator, editor, manager, and the corporate company field.
- Title, Subject, and Keywords are untouched, so a records index that keys on them keeps working.
removePropertiesreturns the affected count rather than a boolean.
Wrap the whole thing in try/finally with metadata.close() in the finally. The binding holds the file open until then, and a loop without it runs out of handles.
Step 2 - Select properties by name
Comment threads, revision counters, and server fields carry no tag. For those, WithNameSpecification(needle, false) matches any property whose name contains the needle, and a four-line builder chains one per substring:
let spec = null;
for (const needle of needles) {
const s = new groupdocs.WithNameSpecification(needle, false /* fullMatch */);
spec = spec ? spec.or(s) : s;
}
return spec;
Three passes reuse that builder with different lists. Comments go first:
const affected = metadata.removeProperties(
nameContainsSpec(['Comment', 'Reviewer', 'Reviewed']));
metadata.save(outputPath);
The editing timeline is the group that tends to be forgotten, and it is the one that describes how the document was produced:
const affected = metadata.removeProperties(nameContainsSpec([
'Revision', 'TrackedChange', 'LastPrinted', 'TotalEditingTime', 'EditTime',
]));
metadata.save(outputPath);
The SharePoint pass is the same call with Server, Workflow, Approver, ContentType, and Template. Substring matching is deliberate: it catches CommentsCount alongside Comment without maintaining an exact-name list per format.
Step 3 - Wipe everything when selectivity stops helping
For the copy that leaves the organization, one call replaces the four passes:
const affected = metadata.sanitize();
metadata.save(outputPath);
sanitize() clears every metadata package the library detects, custom OOXML parts included, and its count usually exceeds the sum of the targeted passes. It also takes Title and Subject, which is why it belongs at the boundary rather than in a review loop.
Step 4 - Verify, because a silent miss looks like success
The scan reuses the same specifications through findProperties, which reads without writing. The result is a Java collection, so it is walked by index:
const props = metadata.findProperties(tagSpec.or(nameSpec));
for (let i = 0; i < props.getCount(); i++) {
const p = props.get_Item(i);
const val = p.getValue && p.getValue();
const value = val ? String(val.getRawValue ? val.getRawValue() : val) : '';
if (!value || value === '0' || value === '0.0') continue;
leaks.push(`${p.getName()}=${value}`);
}
The empty-and-zero filter earns its place. I added it after a run failed on a revision counter that had been cleared to 0, which the scan was faithfully reporting as a surviving property.
Complete Working Example
The repository wires the six functions into index.js, which applies the license, runs each pass against resources/pii-sample.docx, asserts that every output file exists, and finishes by asserting that the leak list is empty. A failed assertion exits with a non-zero code, so the whole thing works as a check in CI rather than a demo you read.
One detail is worth copying into your own version: each pass reads the same source file and writes a separate output, rather than chaining one cleaned file into the next. That keeps the affected counts independent, so a log line for the comment pass reports what the comment rule found rather than what was left after the identity rule ran.
When should I run a targeted pass instead of sanitize()?
Whenever the document is still in use. Files circulating between reviewers rely on Title, Subject, and Keywords for search and classification, and sanitize() removes all three along with the personal data. Run the identity and comment passes during collaboration, keep the descriptive fields intact, and save the full wipe for the copy that actually leaves.
Real-World Applications
Upload handler
An Express route sanitizes an attachment before writing it to storage, records the affected counts on the ticket, and rejects the upload when the leak list is non-empty.
Nightly export job
A worker walks an export folder, applies the identity and server passes, and fails the job rather than logging a warning when a document still reports residual PII.
Pre-publication gate
A build step sanitizes documentation attachments before release, using sanitize() because nothing in those files needs its metadata preserved.
Best Practices and Tips
- Always write to a new path so the original survives for dispute resolution.
- Close the metadata object in a
finallyblock, especially in loops. - Merge specifications with
.or()in batch jobs; one open and one save beats four. - Log the affected count per pass, including zeros, so an unrecognized format is visible.
Troubleshooting Common Issues
The affected count is zero on a document you know is dirty Check that the input format is recognized before concluding the file was clean; an unread file and a clean file produce the same zero.
The leak check reports properties you just removed Point it at the saved output path, not the input path. The scan reads whatever file it is given.
Comment balloons are still visible in Word Comment text lives in the document body, not in a metadata package. GroupDocs.Metadata clears the comment-related properties; removing the balloons themselves needs a content-editing library such as Aspose.Words.
Conclusion
Four steps, six functions, one script that reports what it did. Tag specifications handle the identity group across formats, name specifications cover the families tags do not classify, sanitize() handles the boundary, and the leak scan turns the whole thing into a check. Clone the repository, run it against a document that has been through a real review round, and look at the counts before deciding which passes your pipeline needs.