Tidy a folder every night
What it does. Every night at 02:00 the script looks at an Inbox folder, takes the files that haven’t changed for 30 days, and moves them into Archive/<year>. It moves at most 200 files a run, so a big backlog clears over a few nights instead of timing out.
What you need.
- The MaSH feature.
- A schedule on Settings > MaSH > Schedule:
- Choose Add schedule, then pick the script.
- Set Frequency to Daily, Hour to
02:00, and your Timezone. - Under Files to run on, choose Folder listing, then the share and the path (
Inbox). - Save.
- Script settings:
ARCHIVE(the archive folder, defaultArchive) andDAYS(default 30).
A schedule hands the script its files in mash.event.files, once per run. See Schedules.
The decision, on its own
Section titled “The decision, on its own”The only real logic is “is this old enough?” and “where does it go?”. Both are easy to get wrong with dates, so test them with fixed dates first:
block isOldEnough modified days today set cutoff to today.copy().subDays(days) return modified.lt(cutoff)end
block archiveFor root modified return "{{root}}/" ~ modified.format("Y")end
set today to mash.date.parse("2026-11-03")
each [["2026-09-01", "a.pdf"], ["2026-10-25", "b.pdf"], ["2025-12-30", "c.pdf"]] as pair set modified to mash.date.parse(pair[0]) if isOldEnough(modified, 30, today) printline "{{pair[1]}}: move to " ~ archiveFor("Archive", modified) else printline "{{pair[1]}}: keep" endenda.pdf: move to Archive/2026b.pdf: keepc.pdf: move to Archive/2025block isOldEnough(modified, days, today) { cutoff = today.copy().subDays(days) return modified.lt(cutoff)}
block archiveFor(root, modified) { return "{{root}}/" ~ modified.format("Y")}
today = mash.date.parse("2026-11-03")
each ([["2026-09-01", "a.pdf"], ["2026-10-25", "b.pdf"], ["2025-12-30", "c.pdf"]]) as pair { modified = mash.date.parse(pair[0]) if (isOldEnough(modified, 30, today)) { printline("{{pair[1]}}: move to " ~ archiveFor("Archive", modified)) } else { printline("{{pair[1]}}: keep") }}a.pdf: move to Archive/2026b.pdf: keepc.pdf: move to Archive/2025copy() matters. Dates in MaSH are mutable, so today.subDays(30) would change today itself. Work on a copy.
The script
Section titled “The script”set errorblock to "onError"block onError printline "Error on line {{mash.error.line}}: {{mash.error.message}}"end
block isOldEnough modified days today set cutoff to today.copy().subDays(days) return modified.lt(cutoff)end
set archiveRoot to mash.config.get("ARCHIVE") ?? "Archive"set days to mash.config.get("DAYS") ?? 30set maxPerRun to 200
set files to mash.event.filesif not files or count(files) is 0 printline "Nothing in the folder." returnend
set today to mash.date.now()set moved to 0set kept to 0
each files as doc if moved is more than or equal to maxPerRun break end
if doc.is_dir next end
if not isOldEnough(doc.modified, days, today) set kept to kept + 1 next end
set year to doc.modified.format("Y") set yearFolder to mash.files.get(doc.share, archiveRoot).make(year, true)
# Never overwrite a file already in the archive. if mash.files.exists(doc.share, yearFolder.path ~ "/" ~ doc.name) printline "{{doc.name}} is already in {{yearFolder.path}}, leaving it." next end
doc.move(yearFolder) set moved to moved + 1end
printline "Moved {{moved}}, kept {{kept}}."
if mash.event.files_truncated printline "More files matched than were handed over. The next run will carry on."end
if moved is more than or equal to maxPerRun printline "Hit the limit of {{maxPerRun}} for this run. The rest wait for the next one."enderrorblock = "onError"block onError() { printline("Error on line {{mash.error.line}}: {{mash.error.message}}")}
block isOldEnough(modified, days, today) { cutoff = today.copy().subDays(days) return modified.lt(cutoff)}
archiveRoot = mash.config.get("ARCHIVE") ?? "Archive"days = mash.config.get("DAYS") ?? 30maxPerRun = 200
files = mash.event.filesif (not files || count(files) == 0) { printline("Nothing in the folder.") return}
today = mash.date.now()moved = 0kept = 0
each (files) as doc { if (moved >= maxPerRun) { break }
if (doc.is_dir) { next }
if (not isOldEnough(doc.modified, days, today)) { kept = kept + 1 next }
year = doc.modified.format("Y") yearFolder = mash.files.get(doc.share, archiveRoot).make(year, true)
# Never overwrite a file already in the archive. if (mash.files.exists(doc.share, yearFolder.path ~ "/" ~ doc.name)) { printline("{{doc.name}} is already in {{yearFolder.path}}, leaving it.") next }
doc.move(yearFolder) moved = moved + 1}
printline("Moved {{moved}}, kept {{kept}}.")
if (mash.event.files_truncated) { printline("More files matched than were handed over. The next run will carry on.")}
if (moved >= maxPerRun) { printline("Hit the limit of {{maxPerRun}} for this run. The rest wait for the next one.")}How it works
Section titled “How it works”- The batch cap (
maxPerRun) keeps the run inside its time limit. A queued run has 60 seconds. Moving a file takes a moment, and a large backlog would hit the limit and stop in the middle. See Limits. - Moved files leave the folder. Tomorrow’s listing no longer contains them, so the next run carries on from where this one stopped. That’s the whole trick for big jobs: take a bounded batch, and let the schedule start the next.
files_truncatedsays when the schedule’s own list was cut at 1,000 files. Both caps lead to the same behaviour, a message and another go tomorrow.doc.move(yearFolder)takes the folder object, not a path in text. A path would be read on the file’s own share.nextandbreakskip a file and leave the loop. MaSH calls the first onenext, notcontinue. See Statements.- Idempotent. Running the script twice in one night is harmless: the second run finds less to do.
Variations
Section titled “Variations”- Delete instead of archive. Use
doc.delete(), but only after you’ve watched the move version behave for a week. - Tidy by a field. Use Files matching field criteria in the schedule, for example Status =
Closed, and drop the age test. - Report what moved. Collect the names in a list and write them to a CSV. See Log each document’s fields to a CSV.
- Warn before deleting. Run a second, weekly schedule that emails the list of files that will go next week. See Send an email from a template.
- Stop a second copy starting. Add a script setting
MASH_SINGLE_INSTANCEset to true. A new scheduled start is then skipped while the last is still running.
Library pages used: Files, Date, WrappedCarbon.