My Sunday afternoon project.
I imagine that many people have the same problem I do. On my daily-driver Windows PC, tons of stuff gets dumped into the Downloads folder and it can become a bit crazy after a while.
I've seen examples of people building AutoHotKey scripts to help sort out the folder but Node-RED does it better - much better!
So here is my quick flow for organising it:
[{"id":"watch","type":"watch","z":"tab_downloads","name":"Watch Downloads","files":"D:\\\\Downloads","recursive":false,"x":150,"y":140,"wires":[["6b9199e0f36c15de"]]},{"id":"moveImage","type":"function","z":"tab_downloads","name":"Move files","func":"/**\n * File file types\n */\n\nconst file = msg.fileName // normalised with path\nconst ext = path.extname(file).toLowerCase()\nconst dir = path.dirname(file)\nlet targetDir = null\n\n// Extend this object with other file types as needed\nconst types = {\n images: [\n '.jpg','.jpeg','.png','.gif','.bmp','.webp','.tif','.tiff','.svg','.heic','.avif'\n ]\n}\n\nfor (const type in types) {\n // Add additional cases to match the file types to process\n switch (type) {\n case \"images\": {\n if (types.images.includes(ext)) {\n targetDir = path.join(dir,'images')\n } \n break\n }\n }\n\n}\n\nif (targetDir === null) {\n node.warn(`File extension ${ext} not recognized, no target directory set.`)\n return null\n}\n\n// Make sure target folder exists\nif (!fs.existsSync(targetDir)) {\n fs.mkdirSync(targetDir,{recursive:true})\n}\n\nconst dest = path.join(targetDir,path.basename(file))\ntry {\n fs.renameSync(file,dest)\n node.status({ fill: 'green', shape: 'dot', text: `MOVED: ${path.basename(file)}` })\n node.warn(`Moved file ${file} to ${dest}`)\n} catch(err){\n node.status({ fill: 'red', shape: 'dot', text: `FAILED: ${path.basename(file)}` })\n node.error(`Move failed. ${file} to ${dest}. ${err.message}`, msg)\n return\n}\n","outputs":0,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[{"var":"fs","module":"fs"},{"var":"path","module":"path"}],"x":580,"y":140,"wires":[]},{"id":"injectHourly","type":"inject","z":"tab_downloads","name":"Run","props":[],"repeat":"","crontab":"","once":false,"onceDelay":"5","topic":"","x":130,"y":40,"wires":[["archiveOld"]]},{"id":"archiveOld","type":"function","z":"tab_downloads","name":"Archive files >6 months","func":"/**\n * Archive downloads >6m old\n * by moving to an \"archive\" sub-folder\n */\n\nconst downloadDir ='D:\\\\Downloads'\nconst archiveDir=path.join(downloadDir,'archive')\nlet out = {\n archived: [],\n failed: [],\n}\n\nif(!fs.existsSync(archiveDir)) {\n fs.mkdirSync(archiveDir,{recursive:true})\n}\n\nconst cutoff = Date.now() - 15811200000 // = 183*24*60*60*1000, 6 months\nconst dir = fs.readdirSync(downloadDir, { withFileTypes: true, recursive: false })\n\nfor(const item of dir){\n // Ignore sub-folders, links, etc\n if (!item.isFile()) continue\n\n const full = path.join(downloadDir, item.name)\n\n let stat\n try{\n stat = fs.statSync(full)\n } catch(e){\n node.warn(e.message)\n out.failed.push(item.name)\n continue // Couldn't stat the file so ignore\n }\n\n if(stat.mtimeMs < cutoff){\n try{\n fs.renameSync(full, path.join(archiveDir, item.name))\n out.archived.push(item.name)\n } catch(err){\n node.warn(err.message)\n out.failed.push(item.name)\n }\n }\n}\n\nnode.status({ fill: 'green', shape: 'dot', text: `ARCHIVED: ${out.archived.length}, FAILED: ${out.failed.length}` })\n\nreturn { payload: out}","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[{"var":"path","module":"node:path"},{"var":"fs","module":"node:fs"}],"x":330,"y":40,"wires":[["e6a2ccb53829bbe3"]],"info":"Archive downloads >6m old\r\nby moving to an \"archive\" sub-folder"},{"id":"6b9199e0f36c15de","type":"function","z":"tab_downloads","name":"Stable File Size Watcher","func":"/** \n * Taking input from a watch node, ignore changes for all folders,\n * files not in the watch root and all files ending in .crdownload or .tmp.\n * For files being processed, check file size, wait for x seconds, check again.\n * If file size is different, repeat. Only if file size is stable, output.\n */\n\n// Function to check if the file size is stable\nasync function isFileSizeStable(fname, delay) {\n let previousSize = -1\n while (true) {\n const stats = await fsPromises.stat(fname)\n const currentSize = stats.size\n if (currentSize === previousSize) {\n return true\n }\n previousSize = currentSize\n await new Promise(resolve => setTimeout(resolve, delay))\n }\n}\n\nmsg.rootDir = path.normalize(`${msg.topic}\\\\`)\nconst fullName = msg.fileName = path.normalize(msg.filename)\nconst fName = fullName.replace(msg.rootDir, '')\n\n// Check if the file is in the root directory\n// msg.dirName = path.dirname(msg.filename)\n// msg.path = fileName.split(/\\/|\\\\/)\n// msg.pathLength = fileName.split(/\\/|\\\\/).length\nconst isInRoot = msg.isInRoot = fName.split(/\\/|\\\\/).length === 1\n\n// Ignore changes for all folders and specific file extensions\nif (\n !isInRoot ||\n msg.event === 'remove' ||\n msg?.size === 0 ||\n msg.type === 'directory' ||\n fName.endsWith('.crdownload') ||\n fName.endsWith('.tmp')\n ) {\n return [null, msg]\n}\n\n// Wait for the file size to stabilize\nconst delay = 5000 // x seconds\ntry {\n await isFileSizeStable(fullName, delay)\n} catch (e) {\n node.error(`Error checking file size stability: ${e.message}`, msg)\n return [null, msg]\n}\n\n// Output the message once the file size is stable\nreturn [msg, null]","outputs":2,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"fsPromises","module":"node:fs/promises"},{"var":"path","module":"node:path"}],"x":370,"y":140,"wires":[["moveImage"],[]],"inputLabels":["Watch node input"],"outputLabels":["Files to process","Errors and ignores"]},{"id":"e6a2ccb53829bbe3","type":"debug","z":"tab_downloads","name":"debug 24","active":true,"tosidebar":true,"console":false,"tostatus":true,"complete":"payload","targetType":"msg","statusVal":"","statusType":"counter","x":495,"y":40,"wires":[],"l":false}]
If you want to use this flow, do check and change the location of the downloads folder.
The top flow should be run periodically to move old files into an archive sub-folder. The other flow watches the folder continuously, filters our the (often dreadful) noise from the filing system and moves image files to an images sub-folder.
That second flow can easily be expanded to auto-file other file types as well based on the file extensions.
These flows should run on other OS's as well though I've not tested that. They use node.js core libraries to try to ensure that folder names, etc are valid on each platform.
PS: You can also find this flow on the flows site: Downloads folder automation (flow) - Node-RED