Log each document's fields to a CSV
What it does. Each time a document’s fields are filled in, the script adds one row to a CSV file: when it was logged, which share and path, the file name, and the fields you choose. People open the CSV in Excel. Another system can pick it up. A document is logged once.
What you need.
- The MaSH feature.
- A log share to hold the CSV. Keep it away from the share the documents arrive on (see “Why a different share” below).
- Triggers on the documents’ share: Captur Extracted if Captur fills the fields, Fields Changed if people do. See Events.
- Script settings:
CSV_SHARE(required),CSV_NAME(defaultlog.csv) andFIELDS(the field names, in column order, separated by commas).
The CSV, on its own
Section titled “The CSV, on its own”A CSV writer starts empty or from existing text, takes one row at a time, and turns into text. You can see the shape without a share:
set header to ["Logged at", "Share", "Path", "File name", "Customer", "Order No"]set row to ["2026-11-03 09:15:00 +00:00", "Claims", "Inbox/n1.pdf", "n1.pdf", "Acme, Ltd", "SO-1"]
set writer to mash.csv.writer()writer.insertOne(header)writer.insertOne(row)
printline writer.toString()"Logged at",Share,Path,"File name",Customer,"Order No""2026-11-03 09:15:00 +00:00",Claims,Inbox/n1.pdf,n1.pdf,"Acme, Ltd",SO-1header = ["Logged at", "Share", "Path", "File name", "Customer", "Order No"]row = ["2026-11-03 09:15:00 +00:00", "Claims", "Inbox/n1.pdf", "n1.pdf", "Acme, Ltd", "SO-1"]
writer = mash.csv.writer()writer.insertOne(header)writer.insertOne(row)
printline(writer.toString())"Logged at",Share,Path,"File name",Customer,"Order No""2026-11-03 09:15:00 +00:00",Claims,Inbox/n1.pdf,n1.pdf,"Acme, Ltd",SO-1The writer adds quotes where a value holds a comma or a space, so a customer called Acme, Ltd stays in one cell.
The script
Section titled “The script”set errorblock to "onError"block onError printline "Error on line {{mash.error.line}}: {{mash.error.message}}"end
set csvShare to mash.config.get("CSV_SHARE")set csvName to mash.config.get("CSV_NAME") ?? "log.csv"set fieldList to mash.str(mash.config.get("FIELDS") ?? "").trim().toString()
if not csvShare fail "CSV_SHARE is not set on this automation."end
set doc to mash.event.fileif not doc returnendif doc.is_dir returnendif doc.name is csvName returnend
# The columns.set wanted to []each fieldList.split(",") as name set name to mash.str(name).trim().toString() if name is not "" wanted[] = name endend
# The values. Indexing a field the share doesn't have is an error, so check.set values to []set captured to falseeach wanted as name set cell to "" if doc.fields.has(name) set parts to [] each mash.collect(doc.fields[name] ?? []) as v set v to mash.str(v).trim().toString() if v is not "" parts[] = v end end set cell to mash.collect(parts).join("; ") end if cell is not "" set captured to true end values[] = "{{cell}}"end
# Nothing filled in yet: log nothing, and wait for the next trigger.if not captured returnend
set header to ["Logged at", "Share", "Path", "File name"]each wanted as name header[] = nameend
set docShare to "{{doc.share.name}}"set docPath to "{{doc.path}}"
set row to [mash.date.now().format("Y-m-d H:i:s P"), docShare, docPath, "{{doc.name}}"]each values as v row[] = vend
if mash.files.exists(csvShare, csvName) set csvFile to mash.files.get(csvShare, csvName) set existing to "{{csvFile.contents}}"
if mash.str(existing).trim().length() is more than 0 set reader to mash.csv.read(csvFile) reader.setHeaderOffset(0)
set haveHeader to mash.collect(reader.getHeader()).join("|") set wantHeader to mash.collect(header).join("|") if haveHeader is not wantHeader fail "{{csvName}} has different columns. Rename it and the next document starts a new one." end
# Already logged? A repeat trigger finds its own row. each reader.getRecords() as record set rowShare to record["Share"] set rowPath to record["Path"] if "{{rowShare}}" is docShare and "{{rowPath}}" is docPath return end end end
# A hand-edited CSV may not end in a newline. if existing is not "" and mash.str(existing).endsWith("\n") is false set existing to "{{existing}}\n" end
set writer to mash.csv.writer(existing) writer.insertOne(row) csvFile.write(writer)else set writer to mash.csv.writer() writer.insertOne(header) writer.insertOne(row) mash.files.get(csvShare, "").write(csvName, writer)end
printline "Logged {{doc.name}} to {{csvName}}"errorblock = "onError"block onError() { printline("Error on line {{mash.error.line}}: {{mash.error.message}}")}
csvShare = mash.config.get("CSV_SHARE")csvName = mash.config.get("CSV_NAME") ?? "log.csv"fieldList = mash.str(mash.config.get("FIELDS") ?? "").trim().toString()
if (not csvShare) { fail "CSV_SHARE is not set on this automation."}
doc = mash.event.fileif (not doc) { return}if (doc.is_dir) { return}if (doc.name == csvName) { return}
# The columns.wanted = []each (fieldList.split(",")) as name { name = mash.str(name).trim().toString() if (name != "") { wanted[] = name }}
# The values. Indexing a field the share doesn't have is an error, so check.values = []captured = falseeach (wanted) as name { cell = "" if (doc.fields.has(name)) { parts = [] each (mash.collect(doc.fields[name] ?? [])) as v { v = mash.str(v).trim().toString() if (v != "") { parts[] = v } } cell = mash.collect(parts).join("; ") } if (cell != "") { captured = true } values[] = "{{cell}}"}
# Nothing filled in yet: log nothing, and wait for the next trigger.if (not captured) { return}
header = ["Logged at", "Share", "Path", "File name"]each (wanted) as name { header[] = name}
docShare = "{{doc.share.name}}"docPath = "{{doc.path}}"
row = [mash.date.now().format("Y-m-d H:i:s P"), docShare, docPath, "{{doc.name}}"]each (values) as v { row[] = v}
if (mash.files.exists(csvShare, csvName)) { csvFile = mash.files.get(csvShare, csvName) existing = "{{csvFile.contents}}"
if (mash.str(existing).trim().length() > 0) { reader = mash.csv.read(csvFile) reader.setHeaderOffset(0)
haveHeader = mash.collect(reader.getHeader()).join("|") wantHeader = mash.collect(header).join("|") if (haveHeader != wantHeader) { fail "{{csvName}} has different columns. Rename it and the next document starts a new one." }
# Already logged? A repeat trigger finds its own row. each (reader.getRecords()) as record { rowShare = record["Share"] rowPath = record["Path"] if ("{{rowShare}}" == docShare && "{{rowPath}}" == docPath) { return } } }
# A hand-edited CSV may not end in a newline. if (existing != "" && mash.str(existing).endsWith("\n") == false) { existing = "{{existing}}\n" }
writer = mash.csv.writer(existing) writer.insertOne(row) csvFile.write(writer)} else { writer = mash.csv.writer() writer.insertOne(header) writer.insertOne(row) mash.files.get(csvShare, "").write(csvName, writer)}
printline("Logged {{doc.name}} to {{csvName}}")How it works
Section titled “How it works”- Columns come from a setting, so one script serves several shares with different fields. Each share’s automation has its own
FIELDSvalue. - A multi-value field (several parties on one claim) is joined with
;into one cell. capturedstays false until at least one field has a value. Without it, the first trigger would add a row of empty cells.- The header check protects the file. If you rename a field or change
FIELDS, the columns no longer match, and the script stops with a clear message instead of adding a crooked row. - The duplicate check reads each existing row and returns if the share and path are already there. The first capture is what’s logged. A later correction doesn’t change the row.
- The write reads the whole file, adds one row and writes it all back. MaSH has no append and no lock.
Why a different share
Section titled “Why a different share”The CSV is a new file, and a new file is crawled and can fire events. The doc.name == csvName guard stops the script logging its own CSV. Keeping the CSV off the watched share keeps it out of the way entirely.
A limit to know about
Section titled “A limit to know about”Two documents that arrive in the same instant can both read the same CSV, and the later write wins. One row is lost, with no error. At a few documents a minute this won’t happen. For a share that takes hundreds of documents an hour, write one small CSV per document into a folder, and join them afterwards.
Variations
Section titled “Variations”- Update instead of skip. To keep the latest values, rebuild the CSV from the old records, replacing the matching row. It costs more per run.
- An Excel file. Use
mash.xlsxormash.sheet. See Build a spreadsheet report of a folder. - Only some documents. Return unless the file name or a field contains a word. Filtering with the trigger’s Path prefix and Extension is cheaper.