{"connections":[{"sourceLocalID":2,"sourcePortID":"outlet_output","targetLocalID":0,"targetPortID":"inlet_input"},{"sourceLocalID":0,"sourcePortID":"outlet_output","targetLocalID":1,"targetPortID":"inlet_input"}],"name":"Clean Trash After 30 Days","nodes":[{"config":{"outputSchema":"{\"type\":\"object\",\"required\":[\"deleted\",\"skipped\",\"errors\",\"summary\"],\"properties\":{\"deleted\":{\"type\":\"integer\"},\"skipped\":{\"type\":\"integer\"},\"errors\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"summary\":{\"type\":\"string\"}}}","script":"set -euo pipefail\n# Runs with tools every Mac has: /bin/bash hands the script below to\n# /usr/bin/osascript as JavaScript. No Swift, no developer tools.\n# Custom Arguments on the schedule: PAYLOAD_days (default 30) and\n# PAYLOAD_dryRun (\"false\" deletes; anything else, or missing, only lists).\n/usr/bin/osascript -l JavaScript <<'JS'\nObjC.import('Foundation');\nObjC.bindFunction('getuid', ['int', []]);\n\nvar fm = $.NSFileManager.defaultManager;\n\n// Error out-params are passed as $() and read back directly. (A Ref() here\n// crashes osascript when the call actually fails.)\nfunction describe(err, fallback) {\n  if (err && !err.isNil()) { return ObjC.unwrap(err.localizedDescription); }\n  return fallback;\n}\n\n// The resource value for one key, or null when there is none.\nfunction value(url, key) {\n  var ref = Ref();\n  var err = $();\n  if (!url.getResourceValueForKeyError(ref, key, err)) { throw new Error(describe(err, 'cannot read item details')); }\n  var v = ref[0];\n  return (v === undefined || v === null || v.isNil()) ? null : v;\n}\n\n// Your Trash, plus your Trash on every mounted volume that has one.\nfunction trashRoots() {\n  var roots = [];\n  var err = $();\n  var home = fm.URLForDirectoryInDomainAppropriateForURLCreateError($.NSTrashDirectory, $.NSUserDomainMask, undefined, false, err);\n  if (!home || home.isNil()) { throw new Error(describe(err, 'cannot find your Trash')); }\n  roots.push(home);\n  var volumes = fm.mountedVolumeURLsIncludingResourceValuesForKeysOptions(undefined, 0);\n  var list = (volumes && !volumes.isNil()) ? volumes.js : [];\n  var uid = String($.getuid());\n  for (var i = 0; i < list.length; i++) {\n    var candidate = list[i].URLByAppendingPathComponent('.Trashes').URLByAppendingPathComponent(uid);\n    if (fm.fileExistsAtPath(candidate.path)) { roots.push(candidate); }\n  }\n  return roots;\n}\n\n// Deletes (or, on a dry run, lists) every item that arrived in one of the\n// roots before the cutoff. Arrival is the date the item was added to the\n// Trash folder, never its modification date. Symbolic links and items with\n// no arrival date are skipped.\nfunction clean(roots, cutoffSeconds, dryRun) {\n  var deleted = 0, skipped = 0, failures = [], wouldDelete = [];\n  var visited = {};\n  for (var r = 0; r < roots.length; r++) {\n    var root = roots[r];\n    var rootPath = ObjC.unwrap(root.URLByStandardizingPath.path);\n    if (visited[rootPath]) { continue; }\n    visited[rootPath] = true;\n    try {\n      var rootLink = value(root, $.NSURLIsSymbolicLinkKey);\n      if (rootLink && rootLink.boolValue) {\n        failures.push('Skipped symbolic-link Trash directory: ' + ObjC.unwrap(root.path));\n        continue;\n      }\n      var err = $();\n      var items = fm.contentsOfDirectoryAtURLIncludingPropertiesForKeysOptionsError(\n        root, $([$.NSURLAddedToDirectoryDateKey, $.NSURLIsSymbolicLinkKey]), 0, err);\n      if (!items || items.isNil()) { throw new Error(describe(err, 'cannot read folder')); }\n      items = items.js;\n      for (var i = 0; i < items.length; i++) {\n        var item = items[i];\n        var name = ObjC.unwrap(item.lastPathComponent);\n        if (name === '.DS_Store' || name === '.localized') { continue; }\n        try {\n          var link = value(item, $.NSURLIsSymbolicLinkKey);\n          var added = value(item, $.NSURLAddedToDirectoryDateKey);\n          if ((link && link.boolValue) || !added) { skipped += 1; continue; }\n          if (!(added.timeIntervalSince1970 < cutoffSeconds)) { continue; }\n          if (dryRun) { wouldDelete.push(name); continue; }\n          var rmErr = $();\n          if (!fm.removeItemAtURLError(item, rmErr)) { throw new Error(describe(rmErr, 'could not delete')); }\n          deleted += 1;\n        } catch (e) {\n          failures.push(name + ': ' + e.message);\n        }\n      }\n    } catch (e) {\n      failures.push(ObjC.unwrap(root.path) + ': ' + e.message);\n    }\n  }\n  return { deleted: deleted, skipped: skipped, failures: failures, wouldDelete: wouldDelete };\n}\n\nfunction run() {\n  var env = $.NSProcessInfo.processInfo.environment;\n  function getenv(name) {\n    var v = ObjC.unwrap(env.objectForKey(name));\n    return typeof v === 'string' ? v : undefined;\n  }\n\n  // days: how long an item stays in the Trash. Empty or missing means 30.\n  // Anything else that is not a number of 0 or more stops the run, so a typo\n  // can never widen what gets deleted.\n  var rawDays = (getenv('PAYLOAD_days') || '').trim();\n  if (rawDays === '') { rawDays = '30'; }\n  if (!/^(\\d+(\\.\\d*)?|\\.\\d+)$/.test(rawDays)) {\n    throw new Error('Custom Argument days must be a number of 0 or more, got \"' + rawDays + '\"');\n  }\n  var days = Number(rawDays);\n  // dryRun: \"true\" lists what would go and deletes nothing; \"false\" deletes.\n  // Missing altogether means a dry run, so a run that does not carry the\n  // schedule's Custom Arguments (Run on the canvas does not) never deletes.\n  var rawDry = getenv('PAYLOAD_dryRun');\n  var dryRun = rawDry === undefined ? true : !/^(false|no|0)$/i.test(rawDry.trim());\n\n  var cutoff = Date.now() / 1000 - days * 24 * 60 * 60;\n  var result;\n  try {\n    result = clean(trashRoots(), cutoff, dryRun);\n  } catch (e) {\n    result = { deleted: 0, skipped: 0, failures: [e.message], wouldDelete: [] };\n  }\n\n  var summary;\n  if (dryRun) {\n    var shown = result.wouldDelete.slice(0, 10).join(', ');\n    var more = result.wouldDelete.length > 10 ? ', and ' + (result.wouldDelete.length - 10) + ' more' : '';\n    summary = 'Dry run, nothing deleted. Would delete ' + result.wouldDelete.length + ' Trash items older than ' + days + ' days'\n      + (result.wouldDelete.length ? ': ' + shown + more : '') + '. '\n      + 'Skipped ' + result.skipped + ' items without a usable arrival date or that were symbolic links. Errors: ' + result.failures.length + '.';\n  } else {\n    summary = 'Deleted ' + result.deleted + ' Trash items older than ' + days + ' days. '\n      + 'Skipped ' + result.skipped + ' items without a usable arrival date or that were symbolic links. Errors: ' + result.failures.length + '.';\n  }\n\n  var out = { deleted: result.deleted, errors: result.failures, skipped: result.skipped, summary: summary };\n  // ASCII-only JSON, the same bytes for a file name in any language.\n  return JSON.stringify(out).replace(/[\\u007f-￿]/g, function (c) {\n    return '\\\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4);\n  });\n}\nJS\n","shell":"/bin/bash"},"customName":"Clean Old Trash","isDisabled":false,"localID":0,"typeID":"action.script","x":450,"y":260},{"config":{"body":"{{output.summary}}","forceDisplay":true,"title":"Trash Cleanup"},"isDisabled":false,"localID":1,"typeID":"action.notification","x":800,"y":320},{"config":{"customArgs":{"days":"30","dryRun":"false"},"customSeconds":60,"interval":"5m","mode":"calendar","time":"09:00","weekdays":"mon,tue,wed,thu,fri,sat,sun"},"customName":"Every Day at 9 AM","isDisabled":false,"localID":2,"typeID":"trigger.schedule","x":100,"y":200},{"config":{"_height":640,"_width":460,"text":"**Clean Trash After 30 Days permanently deletes anything that has sat in your Trash for more than 30 days.**\n\n**What it does**\n- Every day at 9:00 AM local time, **Clean Old Trash** checks your Trash, and your Trash on every connected drive.\n- Each item is judged by the date it arrived in the Trash, never by when the file was last changed. Anything older than 30 days is deleted, a folder with everything inside it. Symbolic links and items with no arrival date are left alone.\n- A notification says how many items were deleted, how many were skipped and how many errors there were.\n\n**Set it up**\n- Deleting is permanent. Nothing this flow deletes can be put back.\n- Turn the flow on.\n- If the notification reports an error about permission, grant Watchflows **Full Disk Access** in System Settings, Privacy & Security, Full Disk Access.\n- Watchflows has to be open for the schedule to run. If it was quit or your Mac was asleep at 9:00 AM, the flow runs once when Watchflows next opens or your Mac wakes. A drive that is not connected is cleaned on a later run.\n\n**Make it yours**\n- On **Every Day at 9 AM**, Custom Arguments hold `days`, how many days an item stays in the Trash (30 unless you change it), and `dryRun`.\n- Set `dryRun` to `true` to preview: the notification lists what would be deleted and nothing is deleted. Set it back to `false` to clean for real. A run without `dryRun` only previews.\n- Change the time or the days of the week on the same trigger."},"isDisabled":false,"localID":3,"typeID":"note","x":100,"y":-472},{"config":{"text":"**Clean Old Trash** uses the JavaScript built into macOS, so there is nothing to install. It reads each item's date added to the Trash folder, which macOS records when the item is moved there. Per-item errors, such as a file it was not allowed to delete, appear in `output.errors` and are counted in the notification."},"isDisabled":false,"localID":4,"typeID":"note","x":470,"y":392}],"version":1}