diff --git a/Source/FileWatcher.spoon/docs.json b/Source/FileWatcher.spoon/docs.json new file mode 100644 index 00000000..88149b21 --- /dev/null +++ b/Source/FileWatcher.spoon/docs.json @@ -0,0 +1,450 @@ +[ + { + "Constant" : [ + + ], + "submodules" : [ + + ], + "Function" : [ + + ], + "Variable" : [ + { + "doc" : "Logger object used within the Spoon. Can be accessed to set the default log level for the messages coming from the Spoon.", + "desc" : "Logger object used within the Spoon. Can be accessed to set the default log level for the messages coming from the Spoon.", + "def" : "FileWatcher.logger", + "stripped_doc" : [ + "Logger object used within the Spoon. Can be accessed to set the default log level for the messages coming from the Spoon." + ], + "notes" : [ + + ], + "signature" : "FileWatcher.logger", + "type" : "Variable", + "returns" : [ + + ], + "name" : "logger", + "parameters" : [ + + ] + }, + { + "doc" : "Table containing all active directory watchers. This is managed internally by the Spoon.", + "desc" : "Table containing all active directory watchers. This is managed internally by the Spoon.", + "def" : "FileWatcher.watchers", + "stripped_doc" : [ + "Table containing all active directory watchers. This is managed internally by the Spoon." + ], + "notes" : [ + + ], + "signature" : "FileWatcher.watchers", + "type" : "Variable", + "returns" : [ + + ], + "name" : "watchers", + "parameters" : [ + + ] + } + ], + "stripped_doc" : [ + + ], + "type" : "Module", + "Deprecated" : [ + + ], + "desc" : "File organization spoon that watches directories and moves files based on rules", + "Constructor" : [ + + ], + "doc" : "File organization spoon that watches directories and moves files based on rules\n\nDownload: [https:\/\/github.com\/Hammerspoon\/Spoons\/raw\/master\/Spoons\/FileWatcher.spoon.zip](https:\/\/github.com\/Hammerspoon\/Spoons\/raw\/master\/Spoons\/FileWatcher.spoon.zip)", + "Method" : [ + { + "doc" : "Initialize the spoon\n\nParameters:\n * None\n\nReturns:\n * The FileWatcher object", + "desc" : "Initialize the spoon", + "def" : "FileWatcher:init()", + "stripped_doc" : [ + "Initialize the spoon", + "" + ], + "notes" : [ + + ], + "signature" : "FileWatcher:init()", + "type" : "Method", + "returns" : [ + " * The FileWatcher object" + ], + "name" : "init", + "parameters" : [ + " * None", + "" + ] + }, + { + "doc" : "Process a single file according to the given rules\n\nParameters:\n * file - Full path to the file\n * rules - Array of rules to apply\n\nReturns:\n * true if file was processed successfully, false otherwise", + "desc" : "Process a single file according to the given rules", + "def" : "FileWatcher:processFile(file, rules)", + "stripped_doc" : [ + "Process a single file according to the given rules", + "" + ], + "notes" : [ + + ], + "signature" : "FileWatcher:processFile(file, rules)", + "type" : "Method", + "returns" : [ + " * true if file was processed successfully, false otherwise" + ], + "name" : "processFile", + "parameters" : [ + " * file - Full path to the file", + " * rules - Array of rules to apply", + "" + ] + }, + { + "doc" : "Start watching a directory with the specified rules\n\nParameters:\n * directory - Directory path to watch\n * rules - Array of rules to apply to matching files\n\nReturns:\n * The FileWatcher object\n\nNotes:\n * Each rule should be a table with the following keys:\n * pattern - Lua pattern to match filename (case-insensitive)\n * destination - Path where matching files should be moved\n * action - (optional) Action to take, currently only \"move\" is supported (default: \"move\")\n * The directory path can use tilde (~) for the home directory\n * Files are moved automatically when they appear in the watched directory\n * If a file with the same name exists at the destination, a number will be appended", + "desc" : "Start watching a directory with the specified rules", + "def" : "FileWatcher:watchDirectory(directory, rules)", + "stripped_doc" : [ + "Start watching a directory with the specified rules", + "" + ], + "notes" : [ + " * Each rule should be a table with the following keys:", + " * pattern - Lua pattern to match filename (case-insensitive)", + " * destination - Path where matching files should be moved", + " * action - (optional) Action to take, currently only \"move\" is supported (default: \"move\")", + " * The directory path can use tilde (~) for the home directory", + " * Files are moved automatically when they appear in the watched directory", + " * If a file with the same name exists at the destination, a number will be appended" + ], + "signature" : "FileWatcher:watchDirectory(directory, rules)", + "type" : "Method", + "returns" : [ + " * The FileWatcher object", + "" + ], + "name" : "watchDirectory", + "parameters" : [ + " * directory - Directory path to watch", + " * rules - Array of rules to apply to matching files", + "" + ] + }, + { + "doc" : "Stop watching a directory\n\nParameters:\n * directory - Directory path to stop watching\n\nReturns:\n * The FileWatcher object", + "desc" : "Stop watching a directory", + "def" : "FileWatcher:stopWatching(directory)", + "stripped_doc" : [ + "Stop watching a directory", + "" + ], + "notes" : [ + + ], + "signature" : "FileWatcher:stopWatching(directory)", + "type" : "Method", + "returns" : [ + " * The FileWatcher object" + ], + "name" : "stopWatching", + "parameters" : [ + " * directory - Directory path to stop watching", + "" + ] + }, + { + "doc" : "Stop all directory watchers\n\nParameters:\n * None\n\nReturns:\n * The FileWatcher object", + "desc" : "Stop all directory watchers", + "def" : "FileWatcher:stopAllWatchers()", + "stripped_doc" : [ + "Stop all directory watchers", + "" + ], + "notes" : [ + + ], + "signature" : "FileWatcher:stopAllWatchers()", + "type" : "Method", + "returns" : [ + " * The FileWatcher object" + ], + "name" : "stopAllWatchers", + "parameters" : [ + " * None", + "" + ] + }, + { + "doc" : "Starts all configured directory watchers\n\nParameters:\n * None\n\nReturns:\n * The FileWatcher object\n\nNotes:\n * This method is provided for consistency with other Spoons\n * Watchers are automatically started when you call :watchDirectory()\n * This method is useful if you've stopped all watchers and want to restart them", + "desc" : "Starts all configured directory watchers", + "def" : "FileWatcher:start()", + "stripped_doc" : [ + "Starts all configured directory watchers", + "" + ], + "notes" : [ + " * This method is provided for consistency with other Spoons", + " * Watchers are automatically started when you call :watchDirectory()", + " * This method is useful if you've stopped all watchers and want to restart them" + ], + "signature" : "FileWatcher:start()", + "type" : "Method", + "returns" : [ + " * The FileWatcher object", + "" + ], + "name" : "start", + "parameters" : [ + " * None", + "" + ] + }, + { + "doc" : "Stops all directory watchers\n\nParameters:\n * None\n\nReturns:\n * The FileWatcher object", + "desc" : "Stops all directory watchers", + "def" : "FileWatcher:stop()", + "stripped_doc" : [ + "Stops all directory watchers", + "" + ], + "notes" : [ + + ], + "signature" : "FileWatcher:stop()", + "type" : "Method", + "returns" : [ + " * The FileWatcher object" + ], + "name" : "stop", + "parameters" : [ + " * None", + "" + ] + } + ], + "Command" : [ + + ], + "items" : [ + { + "doc" : "Logger object used within the Spoon. Can be accessed to set the default log level for the messages coming from the Spoon.", + "desc" : "Logger object used within the Spoon. Can be accessed to set the default log level for the messages coming from the Spoon.", + "def" : "FileWatcher.logger", + "stripped_doc" : [ + "Logger object used within the Spoon. Can be accessed to set the default log level for the messages coming from the Spoon." + ], + "notes" : [ + + ], + "signature" : "FileWatcher.logger", + "type" : "Variable", + "returns" : [ + + ], + "name" : "logger", + "parameters" : [ + + ] + }, + { + "doc" : "Table containing all active directory watchers. This is managed internally by the Spoon.", + "desc" : "Table containing all active directory watchers. This is managed internally by the Spoon.", + "def" : "FileWatcher.watchers", + "stripped_doc" : [ + "Table containing all active directory watchers. This is managed internally by the Spoon." + ], + "notes" : [ + + ], + "signature" : "FileWatcher.watchers", + "type" : "Variable", + "returns" : [ + + ], + "name" : "watchers", + "parameters" : [ + + ] + }, + { + "doc" : "Initialize the spoon\n\nParameters:\n * None\n\nReturns:\n * The FileWatcher object", + "desc" : "Initialize the spoon", + "def" : "FileWatcher:init()", + "stripped_doc" : [ + "Initialize the spoon", + "" + ], + "notes" : [ + + ], + "signature" : "FileWatcher:init()", + "type" : "Method", + "returns" : [ + " * The FileWatcher object" + ], + "name" : "init", + "parameters" : [ + " * None", + "" + ] + }, + { + "doc" : "Process a single file according to the given rules\n\nParameters:\n * file - Full path to the file\n * rules - Array of rules to apply\n\nReturns:\n * true if file was processed successfully, false otherwise", + "desc" : "Process a single file according to the given rules", + "def" : "FileWatcher:processFile(file, rules)", + "stripped_doc" : [ + "Process a single file according to the given rules", + "" + ], + "notes" : [ + + ], + "signature" : "FileWatcher:processFile(file, rules)", + "type" : "Method", + "returns" : [ + " * true if file was processed successfully, false otherwise" + ], + "name" : "processFile", + "parameters" : [ + " * file - Full path to the file", + " * rules - Array of rules to apply", + "" + ] + }, + { + "doc" : "Starts all configured directory watchers\n\nParameters:\n * None\n\nReturns:\n * The FileWatcher object\n\nNotes:\n * This method is provided for consistency with other Spoons\n * Watchers are automatically started when you call :watchDirectory()\n * This method is useful if you've stopped all watchers and want to restart them", + "desc" : "Starts all configured directory watchers", + "def" : "FileWatcher:start()", + "stripped_doc" : [ + "Starts all configured directory watchers", + "" + ], + "notes" : [ + " * This method is provided for consistency with other Spoons", + " * Watchers are automatically started when you call :watchDirectory()", + " * This method is useful if you've stopped all watchers and want to restart them" + ], + "signature" : "FileWatcher:start()", + "type" : "Method", + "returns" : [ + " * The FileWatcher object", + "" + ], + "name" : "start", + "parameters" : [ + " * None", + "" + ] + }, + { + "doc" : "Stops all directory watchers\n\nParameters:\n * None\n\nReturns:\n * The FileWatcher object", + "desc" : "Stops all directory watchers", + "def" : "FileWatcher:stop()", + "stripped_doc" : [ + "Stops all directory watchers", + "" + ], + "notes" : [ + + ], + "signature" : "FileWatcher:stop()", + "type" : "Method", + "returns" : [ + " * The FileWatcher object" + ], + "name" : "stop", + "parameters" : [ + " * None", + "" + ] + }, + { + "doc" : "Stop all directory watchers\n\nParameters:\n * None\n\nReturns:\n * The FileWatcher object", + "desc" : "Stop all directory watchers", + "def" : "FileWatcher:stopAllWatchers()", + "stripped_doc" : [ + "Stop all directory watchers", + "" + ], + "notes" : [ + + ], + "signature" : "FileWatcher:stopAllWatchers()", + "type" : "Method", + "returns" : [ + " * The FileWatcher object" + ], + "name" : "stopAllWatchers", + "parameters" : [ + " * None", + "" + ] + }, + { + "doc" : "Stop watching a directory\n\nParameters:\n * directory - Directory path to stop watching\n\nReturns:\n * The FileWatcher object", + "desc" : "Stop watching a directory", + "def" : "FileWatcher:stopWatching(directory)", + "stripped_doc" : [ + "Stop watching a directory", + "" + ], + "notes" : [ + + ], + "signature" : "FileWatcher:stopWatching(directory)", + "type" : "Method", + "returns" : [ + " * The FileWatcher object" + ], + "name" : "stopWatching", + "parameters" : [ + " * directory - Directory path to stop watching", + "" + ] + }, + { + "doc" : "Start watching a directory with the specified rules\n\nParameters:\n * directory - Directory path to watch\n * rules - Array of rules to apply to matching files\n\nReturns:\n * The FileWatcher object\n\nNotes:\n * Each rule should be a table with the following keys:\n * pattern - Lua pattern to match filename (case-insensitive)\n * destination - Path where matching files should be moved\n * action - (optional) Action to take, currently only \"move\" is supported (default: \"move\")\n * The directory path can use tilde (~) for the home directory\n * Files are moved automatically when they appear in the watched directory\n * If a file with the same name exists at the destination, a number will be appended", + "desc" : "Start watching a directory with the specified rules", + "def" : "FileWatcher:watchDirectory(directory, rules)", + "stripped_doc" : [ + "Start watching a directory with the specified rules", + "" + ], + "notes" : [ + " * Each rule should be a table with the following keys:", + " * pattern - Lua pattern to match filename (case-insensitive)", + " * destination - Path where matching files should be moved", + " * action - (optional) Action to take, currently only \"move\" is supported (default: \"move\")", + " * The directory path can use tilde (~) for the home directory", + " * Files are moved automatically when they appear in the watched directory", + " * If a file with the same name exists at the destination, a number will be appended" + ], + "signature" : "FileWatcher:watchDirectory(directory, rules)", + "type" : "Method", + "returns" : [ + " * The FileWatcher object", + "" + ], + "name" : "watchDirectory", + "parameters" : [ + " * directory - Directory path to watch", + " * rules - Array of rules to apply to matching files", + "" + ] + } + ], + "Field" : [ + + ], + "name" : "FileWatcher" + } +] diff --git a/Source/FileWatcher.spoon/init.lua b/Source/FileWatcher.spoon/init.lua new file mode 100644 index 00000000..6a5cb054 --- /dev/null +++ b/Source/FileWatcher.spoon/init.lua @@ -0,0 +1,270 @@ +--- === FileWatcher === +--- +--- File organization spoon that watches directories and moves files based on rules +--- +--- Download: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/FileWatcher.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/FileWatcher.spoon.zip) + +local obj = {} +obj.__index = obj + +-- Metadata +obj.name = "FileWatcher" +obj.version = "1.0" +obj.author = "Daniel Rodríguez" +obj.homepage = "https://github.com/Hammerspoon/Spoons" +obj.license = "MIT - https://opensource.org/licenses/MIT" + +--- FileWatcher.logger +--- Variable +--- Logger object used within the Spoon. Can be accessed to set the default log level for the messages coming from the Spoon. +obj.logger = hs.logger.new("FileWatcher") + +--- FileWatcher.watchers +--- Variable +--- Table containing all active directory watchers. This is managed internally by the Spoon. +obj.watchers = {} + +local function expandTilde(path) + if path:sub(1, 1) == "~" then + return os.getenv("HOME") .. path:sub(2) + end + return path +end + +local function ensureTrailingSlash(path) + if path:sub(-1) ~= "/" then + return path .. "/" + end + return path +end + +local function getFilename(path) + return path:match("([^/]+)$") +end + +local function getFileExtension(filename) + return filename:match("%.([^%.]+)$") or "" +end + +local function getBasename(filename) + local ext = getFileExtension(filename) + if ext ~= "" then + return filename:sub(1, #filename - #ext - 1) + end + return filename +end + +local function directoryExists(path) + local file = io.open(path, "r") + if file then + io.close(file) + return true + end + return false +end + +local function createDirectory(path) + path = expandTilde(path) + if directoryExists(path) then + return true + end + return os.execute('mkdir -p "' .. path .. '"') == 0 +end + +--- FileWatcher:init() +--- Method +--- Initialize the spoon +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The FileWatcher object +function obj:init() + self.watchers = {} + return self +end + +--- FileWatcher:processFile(file, rules) +--- Method +--- Process a single file according to the given rules +--- +--- Parameters: +--- * file - Full path to the file +--- * rules - Array of rules to apply +--- +--- Returns: +--- * true if file was processed successfully, false otherwise +function obj:processFile(file, rules) + local filename = getFilename(file) + if not filename then + return false + end + + for _, rule in ipairs(rules) do + local pattern = rule.pattern + local destination = expandTilde(rule.destination) + local action = rule.action or "move" + + if string.match(filename:lower(), pattern:lower()) then + if action == "move" then + -- Ensure destination directory exists + if not createDirectory(destination) then + self.logger.e(string.format("Failed to create directory %s", destination)) + return false + end + + destination = ensureTrailingSlash(destination) + local destPath = destination .. filename + + -- If file already exists at destination, append number + local counter = 1 + while directoryExists(destPath) do + local ext = getFileExtension(filename) + local basename = getBasename(filename) + destPath = + string.format("%s%s_%d%s", destination, basename, counter, ext ~= "" and "." .. ext or "") + counter = counter + 1 + end + + -- Move the file using os.rename + local success, err = os.rename(file, destPath) + if success then + self.logger.i(string.format("Moved %s to %s", filename, destPath)) + -- Show notification + hs.notify.new({ + title = "File Moved", + informativeText = string.format("%s → %s", filename, destination:gsub(os.getenv("HOME"), "~")), + withdrawAfter = 3 + }):send() + else + self.logger.e(string.format("Failed to move %s to %s: %s", filename, destPath, err)) + end + return success + end + end + end + return false +end + +--- FileWatcher:watchDirectory(directory, rules) +--- Method +--- Start watching a directory with the specified rules +--- +--- Parameters: +--- * directory - Directory path to watch +--- * rules - Array of rules to apply to matching files +--- +--- Returns: +--- * The FileWatcher object +--- +--- Notes: +--- * Each rule should be a table with the following keys: +--- * pattern - Lua pattern to match filename (case-insensitive) +--- * destination - Path where matching files should be moved +--- * action - (optional) Action to take, currently only "move" is supported (default: "move") +--- * The directory path can use tilde (~) for the home directory +--- * Files are moved automatically when they appear in the watched directory +--- * If a file with the same name exists at the destination, a number will be appended +function obj:watchDirectory(directory, rules) + -- Expand the directory path + directory = expandTilde(directory) + directory = ensureTrailingSlash(directory) + self.logger.i("Attempt to watch directory: " .. directory) + + -- Create watcher for the directory + local watcher = hs.pathwatcher.new(directory, function(files) + for _, file in ipairs(files) do + -- Check if it's a file (not a directory) using io.open + local f = io.open(file, "r") + if f then + f:close() + self:processFile(file, rules) + end + end + end) + + -- Start the watcher + watcher:start() + + -- Store the watcher + self.watchers[directory] = { + watcher = watcher, + rules = rules, + } + + self.logger.i(string.format("Started watching %s", directory)) + return self +end + +--- FileWatcher:stopWatching(directory) +--- Method +--- Stop watching a directory +--- +--- Parameters: +--- * directory - Directory path to stop watching +--- +--- Returns: +--- * The FileWatcher object +function obj:stopWatching(directory) + directory = expandTilde(directory) + directory = ensureTrailingSlash(directory) + + if self.watchers[directory] then + self.watchers[directory].watcher:stop() + self.watchers[directory] = nil + self.logger.i(string.format("Stopped watching %s", directory)) + end + return self +end + +--- FileWatcher:stopAllWatchers() +--- Method +--- Stop all directory watchers +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The FileWatcher object +function obj:stopAllWatchers() + for directory, _ in pairs(self.watchers) do + self:stopWatching(directory) + end + return self +end + +--- FileWatcher:start() +--- Method +--- Starts all configured directory watchers +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The FileWatcher object +--- +--- Notes: +--- * This method is provided for consistency with other Spoons +--- * Watchers are automatically started when you call :watchDirectory() +--- * This method is useful if you've stopped all watchers and want to restart them +function obj:start() + -- Watchers are started automatically in watchDirectory + -- This method is here for API consistency + return self +end + +--- FileWatcher:stop() +--- Method +--- Stops all directory watchers +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The FileWatcher object +function obj:stop() + return self:stopAllWatchers() +end + +return obj