diff --git a/README.md b/README.md index 3c9a48f..416b5ba 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ FlickSave -- automatic snapshot each time you hit save ====================================================== -FlickSave automagically perform an action each time you touch a watched file. +FlickSave automagically perform an action each time you touch watched files. For instance, FlickSave can backup a timestamped snapshot of a file that you edit with any program, without getting in your way. @@ -15,29 +15,30 @@ You can even do an automatic Git commit the same way. ## Usage -You should start FlickSave with the file you want to save as an argument -and let it run during the time you want to let it perform your action. -Once started, it will watch for modifications on the given file +You should start FlickSave with the files you want to save as argument(s) +and let it run during the time you want to perform your action(s). +Once started, it will watch for modifications on the given file(s) and, for instance, create the snapshots by himself each time something happens. Currently, FlickSave can perform the following actions: -- `--save`: save a timestamped snapshot of the watched file, -- `--inkscape`: export a timestamped PNG of a watched SVG, -- `--git`: commit the modifications of the watched file, -- `--log`: print a message stating that the watched file was touched. +- `--save`: save a timestamped snapshot of the watched file(s), +- `--inkscape`: export a timestamped PNG(s) of a watched SVG(s), +- `--git`: commit the modifications of the watched file(s), +- `--log`: print a message stating that the watched file(s) were touched. - `--dbus`: send a notification to the system's D-Bus. You can pass multiple actions. For instance, you can both export a PNG and do a Git commit. -By default, timestamped snapshots have the same name than the targeted file, with the addition of the timestamp. +By default, timestamped snapshots have the same name than the targeted files, +with the addition of the timestamp. For instance, with a target file named `test.svg`, a snapshot will look like `test_2017-11-28T21:02:59.svg`. ### Synopsis -`flicksave.py [-h] [--save] [--inkscape] [--git] [--log] [--dbus] [-d DIRECTORY] [-y DELAY] [-s SEPARATOR] [-t TEMPLATE] [-w] [-v {DEBUG,INFO,WARNING,ERROR}] [-e {opened,moved,deleted,created,modified,closed}...] target` +`flicksave.py [-h] [--save] [--inkscape] [--git] [--log] [--dbus] [-d DIRECTORY] [-y DELAY] [-s SEPARATOR] [-t TEMPLATE] [-w] [-v {DEBUG,INFO,WARNING,ERROR}] [-e {opened,moved,deleted,created,modified,closed}...] target [target ...]` ### Required positional argument @@ -73,23 +74,33 @@ As usual, hit `Ctrl-C` (or close the terminal) to stop FlickSave. If you want to specify a directory in which to put your snapshots, with no more than one separate file every minute: - $ flicksave --inkscape -d flicksave -y 60 my_file.svg + $ flicksave --inkscape --directory flicksave --delay 60 my_file.svg + +You can call sevral actions, for instance export a PNG and commit the source SVG: + + $ flicksave --inkscape --git my_file.svg + +You can use your shell to create the file list: + + $ flicksave --git *.py + +And even handle files in subdirectories: + + $ flicksave --log */*.log You may want to save both the file before and after it was modified by any program: $ flicksave --save --events opened closed --no-overwrite my_file -If you want to see what's going on and when the action(s) are called, -ask it to be more verbose: +You can export PNGs and commit their source across sub-directories, +and be notified when it's done: - $ touch test.txt - $ flicksave --log -v INFO test.txt & - [1] 4303 - echo "." >> test.txt - 2017-11-29T21:15:51 -- ./test.txt -> ./test_2017-11-29T21:15:51.txt - $ kill 4303 - [1]+ Complété ./flicksave.py -v INFO test.txt + $ flicksave --inkscape --git --dbus --events closed */*.svg + +You can also prepare the list of files to watch by using a subcommand: + + $ flicksave --log $(find . -type f -name *.png | grep -v test) ## Authors diff --git a/flicksave.py b/flicksave.py index 54a65d5..51f6172 100755 --- a/flicksave.py +++ b/flicksave.py @@ -2,6 +2,7 @@ #encoding: utf-8 import os +import sys import time import glob import shutil @@ -12,8 +13,8 @@ import subprocess try: from sdbus_block.notifications import FreedesktopNotifications except Exception as e: - logging.error(e) - logging.error("Suitable `sdbus` module cannot be loaded, the --dbus action is disabled.") + print("WARNING:", e, file=sys.stderr) + print("WARNING: Suitable `sdbus` module cannot be loaded, the --dbus action is disabled.", file=sys.stderr) HAS_DBUS = False else: HAS_DBUS = True @@ -39,27 +40,66 @@ class Flick: class Flicker: """Build a new timestamped file name.""" - def __init__(self, target, delay=10): - self.target = target + def __init__(self, targets, delay=10): + self.targets = targets self.delay = delay - self.last_date = None + self.last_date = {} - def __iter__(self): - return self + def __call__(self): + for target in self.targets: + name,ext = os.path.splitext(target) + date_now = datetime.datetime.now() - def next(self): - name,ext = os.path.splitext(self.target) - date_now = datetime.datetime.now() + if target in self.last_date: + logging.debug(f"Target already actionned at {self.last_date[target]}") + logging.debug("Current date: %s", date_now.isoformat()) + assert(self.last_date[target] <= date_now) - if self.last_date: - logging.debug("Current date: %s", date_now.isoformat()) - assert(self.last_date <= date_now) + if date_now - self.last_date[target] < datetime.timedelta(seconds=self.delay): + logging.debug("Delay passed, current delta: %s < %s", date_now - self.last_date[target],datetime.timedelta(seconds=self.delay)) + yield Flick(target, self.last_date[target], ext) + self.last_date[target] = date_now + else: + logging.debug("Delay not passed") + else: + self.last_date[target] = date_now + yield Flick(target, date_now, ext) - if date_now - self.last_date < datetime.timedelta(seconds=self.delay): - logging.debug("Current delta: %s < %s", date_now - self.last_date,datetime.timedelta(seconds=self.delay)) - return Flick(self.target, self.last_date, ext) - return Flick(self.target, date_now, ext) +class Handler(FileSystemEventHandler): + """Event handler, will call a sequence of operators at each event matching the target.""" + def __init__(self, operators, flicker, watched_events = ["modified"] ): + self.flicker = flicker + self.ops = operators + self.watched_events = watched_events + + def on_any_event(self, event): + logging.debug(f"##############################\n\t\t\tRECEIVED EVENT:") + logging.debug(f"{event}") + super(Handler,self).on_any_event(event) + for flick in self.flicker(): + logging.debug(f"Created flick: {flick}") + # Watchdog cannot watch single files (FIXME bugreport?), + # so we filter it in this event handler. + if (not event.is_directory) and os.path.abspath(event.src_path) == os.path.abspath(flick.target) and event.event_type in self.watched_events: + logging.debug("Handle event") + logging.debug("New flick for %s: %s", event.src_path, flick) + + for op in self.ops: + logging.debug("Calling %s", op) + op(os.path.abspath(event.src_path), flick, event) + else: + is_dir = "" + if event.is_directory: + is_dir = " is a directory" + is_not_target = "" + if os.path.abspath(event.src_path) != os.path.abspath(flick.target): + is_not_target = " is not a target" + is_not_watched = "" + if event.event_type not in self.watched_events: + is_not_watched = " is not a watched event" + + logging.debug("Not handling event:" + ", ".join([i for i in [is_dir, is_not_target, is_not_watched] if i != ""])) class Operator: @@ -81,6 +121,9 @@ class Save(Operator): self.date_sep = date_sep self.no_overwrite = no_overwrite + # Alternate last_date, xtracted from the file's timestamp. + self.last_date = None + # Make a glob search expression with the date template. self.fields = {'Y':4,'m':2,'d':2,'H':2,'M':2,'S':2} self.glob_template = self.date_template @@ -211,7 +254,7 @@ if HAS_DBUS: return "Log()" def __call__(self, target, flick, event, alt_ext = None): - logging.info("Event(s) seen for {}: {}".format(target,flick)) + logging.info(f"File {target} was {event.event_type}") notif = FreedesktopNotifications() hints = notif.create_hint( urgency = 0, @@ -226,45 +269,25 @@ if HAS_DBUS: ) -class Handler(FileSystemEventHandler): - """Event handler, will call a sequence of operators at each event matching the target.""" - def __init__(self, operators, flicker, watched_types = ["modified"] ): - self.flicker = flicker - self.ops = operators - self.watched_types = watched_types - - def on_any_event(self, event): - logging.debug("Received event %s",event) - super(Handler,self).on_any_event(event) - # Watchdog cannot watch single files (FIXME bugreport?), - # so we filter it in this event handler. - if (not event.is_directory) and os.path.abspath(event.src_path) == os.path.abspath(self.flicker.target) and event.event_type in self.watched_types: - logging.debug("Handle event") - flick = self.flicker.next() - logging.debug("New flicker for %s: %s", event.src_path, flick) - - for op in self.ops: - logging.debug("Calling %s", op) - op(os.path.abspath(event.src_path), flick, event) - else: - logging.debug("Not handling event: file={}, is_directory={}, watched_types={}".format(os.path.abspath(event.src_path),event.is_directory, self.watched_types)) - - -def flicksave(target, operators=None, delay=10, watched=["modified"]): +def flicksave(globbed_targets, operators=None, delay=10, watched=["modified"]): """Start the watch thread.""" - # Handle files specified without a directory. - root = os.path.dirname(target) - if not root: - root = '.' - target = os.path.join(root,target) - flicker = Flicker(target, delay) + targets = [os.path.abspath(p) for p in globbed_targets] + logging.debug(f"Watching {len(targets)} files.") + + root = os.path.commonpath(targets) + if os.path.isfile(root): + root = os.path.dirname(root) + logging.debug(f"Watching at root directory: `{root}`.") + + flicker = Flicker(targets, delay) handler = Handler(operators, flicker, watched) # Start the watch thread. observer = Observer() observer.schedule(handler, root) + logging.debug("Start watching...") observer.start() try: while True: @@ -303,8 +326,8 @@ if __name__=="__main__": """) # Required argument. - parser.add_argument("target", - help="The file to save each time it's modified.") + parser.add_argument("targets", nargs="+", + help="The files to save each time it's modified.") # Optional arguments. parser.add_argument("-d","--directory", default='.', @@ -336,16 +359,16 @@ if __name__=="__main__": existing = { "save": - ["Save a snapshot of the target file.", + ["Save a snapshot of the target files.", None], "inkscape": - ["Save a PNG snapshot of the file, using inkscape.", + ["Save a PNG snapshot of the files, using inkscape.", None], "git": - ["Commit the target file if it has been modified [the repository should be set-up].", + ["Commit the target files if it has been modified [the repository should be set-up].", None], "log": - ["Log when the target file is modified.", + ["Log when the target files are touched.", None], } if HAS_DBUS: @@ -355,8 +378,6 @@ if __name__=="__main__": def help(name): return existing[name][0] - def instance(name): - return existing[name][1] for name in existing: parser.add_argument("--"+name, help=help(name), action="store_true") @@ -365,6 +386,8 @@ if __name__=="__main__": logging.basicConfig(level=log_as[asked.verbose], format='%(asctime)s -- %(message)s', datefmt=asked.template) + logging.debug("Load actions...") + # Add instances, now that we have all parameters. available = existing; available["save"][1] = Save(asked.directory, asked.separator, asked.template, asked.no_overwrite) @@ -387,6 +410,8 @@ if __name__=="__main__": operators = [] requested = vars(asked) + def instance(name): + return existing[name][1] for it in [iz for iz in requested if iz in available and requested[iz]==True]: operators.append(instance(it)) @@ -404,5 +429,6 @@ if __name__=="__main__": logging.debug("\t%s", op) # Start it. - flicksave(asked.target, operators, asked.delay, asked.events) + logging.debug(asked.targets) + flicksave(asked.targets, operators, asked.delay, asked.events)