#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Rofi clipboard manager Usage: roficlip.py --daemon [-q | --quiet] roficlip.py --show [--persistent | --actions] [-q | --quiet] [] roficlip.py --add [-q | --quiet ] roficlip.py --remove [-q | --quiet] roficlip.py --edit roficlip.py (-h | --help) roficlip.py (-v | --version) Arguments: Selected item to be used with rofi Commands: --daemon Run clipboard manager daemon. --show Show clipboard history. --persistent Select to show persistent history. --actions Select to show actions defined in config. --add Add current clipboard to persistent storage. --remove Remove current clipboard from persistent storage. --edit Edit persistent storage with text editor. -q, --quiet Do not notify, even if notification enabled in config. -h, --help Show this screen. -v, --version Show version. """ import errno import os import sys import stat import struct from subprocess import Popen, DEVNULL from tempfile import NamedTemporaryFile try: import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk, GLib except ImportError: raise import yaml from docopt import docopt from xdg import BaseDirectory try: import notify2 except ImportError: pass class ClipboardManager(): def __init__(self): # Init databases and fifo name = 'roficlip' self.ring_db = '{0}/{1}'.format(BaseDirectory.save_data_path(name), 'ring.db') self.persist_db = '{0}/{1}'.format(BaseDirectory.save_data_path(name), 'persistent.db') self.fifo_path = '{0}/{1}.fifo'.format(BaseDirectory.get_runtime_dir(strict=False), name) self.config_path = '{0}/settings'.format(BaseDirectory.save_config_path(name)) if not os.path.isfile(self.ring_db): open(self.ring_db, "a+").close() if not os.path.isfile(self.persist_db): open(self.persist_db, "a+").close() if ( not os.path.exists(self.fifo_path) or not stat.S_ISFIFO(os.stat(self.fifo_path).st_mode) ): os.mkfifo(self.fifo_path) self.fifo = os.open(self.fifo_path, os.O_RDONLY | os.O_NONBLOCK) # Init clipboard and read databases self.cb = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) self.ring = self.read(self.ring_db) self.persist = self.read(self.persist_db) # Load settings self.load_config() # Init notifications if self.cfg['notify'] and 'notify2' in sys.modules: self.notify = notify2 self.notify.init(name) else: self.cfg['notify'] = False def daemon(self): """ Clipboard Manager daemon. """ GLib.timeout_add(300, self.cb_watcher) GLib.timeout_add(300, self.fifo_watcher) Gtk.main() def cb_watcher(self): """ Callback function. Watch clipboard and write changes to ring database. Must return "True" for continuous operation. """ clip = self.cb.wait_for_text() if self.sync_items(clip, self.ring): self.ring = self.ring[0:self.cfg['ring_size']] self.write(self.ring_db, self.ring) return True def fifo_watcher(self): """ Callback function. Copy contents from fifo to clipboard. Must return "True" for continuous operation. """ try: fifo_in = os.read(self.fifo, 65536) except OSError as err: if err.errno == errno.EAGAIN or err.errno == errno.EWOULDBLOCK: fifo_in = None else: raise if fifo_in: self.cb.set_text(fifo_in.decode('utf-8'), -1) self.notify_send('Copied to the clipboard.') return True def sync_items(self, clip, items): """ Sync clipboard contents with specified items dict when needed. Return "True" if dict modified, otherwise "False". """ if clip and (not items or clip != items[0]): if clip in items: items.remove(clip) items.insert(0, clip) return True return False def copy_item(self, clip): """ Writes to fifo item that should be copied to clipboard. """ if clip: with open(self.fifo_path, "w") as file: file.write(clip) file.close() def show_items(self, items): """ Format and show contents of specified items dict (for rofi). """ for clip in items: clip = clip.replace('\n', self.cfg['newline_char']) # Move text after last \# to beginning of string if args['--persistent'] and self.cfg['show_comments_first'] and '#' in clip: # Save index of last \# idx = clip.rfind('#') # Format string clip = '{} ➜ {}'.format(clip[idx+1:], clip[:idx]) preview = clip[0:self.cfg['preview_width']] print(preview) def persistent_add(self): """ Add current clipboard to persistent storage. """ clip = self.cb.wait_for_text() if self.sync_items(clip, self.persist): self.write(self.persist_db, self.persist) self.notify_send('Added to persistent.') def persistent_remove(self): """ Remove current clipboard from persistent storage. """ clip = self.cb.wait_for_text() if clip and clip in self.persist: self.persist.remove(clip) self.write(self.persist_db, self.persist) self.notify_send('Removed from persistent.') def persistent_edit(self): """ Edit persistent storage with text editor. New line char will be used as separator. """ editor = os.getenv('EDITOR') if self.persist and editor: try: tmp = NamedTemporaryFile(mode='w+') for clip in self.persist: clip = '{}\n'.format(clip.replace('\n', self.cfg['newline_char'])) tmp.write(clip) tmp.flush() except IOError as e: print("I/O error({0}): {1}".format(e.errno, e.strerror)) else: proc = Popen([editor, tmp.name], stdout=DEVNULL, stderr=DEVNULL) ret = proc.wait() if ret == 0: tmp.seek(0, 0) clips = tmp.read().splitlines() if clips: self.persist = [] for clip in clips: clip = clip.replace('\n', '') clip = clip.replace(self.cfg['newline_char'], '\n') self.persist.append(clip) self.write(self.persist_db, self.persist) finally: tmp.close() def do_action(self, action): """ Run selected action on clipboard contents. """ if action: clip = self.cb.wait_for_text() params = self.actions[action].split(' ') while '%s' in params: params[params.index('%s')] = clip Popen(params, stdout=DEVNULL, stderr=DEVNULL) self.notify_send("Action: {}".format(action)) def notify_send(self, text): """ Show desktop notification. """ if self.cfg['notify']: n = self.notify.Notification("Roficlip", text) n.timeout = self.cfg['notify_timeout'] * 1000 n.show() def read(self, fd): """ Helper function. Binary reader. """ result = [] with open(fd, "rb") as file: bytes_read = file.read(4) while bytes_read: chunksize = struct.unpack('>i', bytes_read)[0] bytes_read = file.read(chunksize) result.append(bytes_read.decode('utf-8')) bytes_read = file.read(4) return result def write(self, fd, items): """ Helper function. Binary writer. """ with open(fd, 'wb') as file: for item in items: item = item.encode('utf-8') file.write(struct.pack('>i', len(item))) file.write(item) def load_config(self): """ Read config if exists, and/or provide defaults. """ # default settings settings = { 'settings': { 'ring_size': 20, 'preview_width': 100, 'newline_char': '¬', 'notify': True, 'notify_timeout': 1, 'show_comments_first': False, }, 'actions': {} } if os.path.isfile(self.config_path): with open(self.config_path, "r") as file: config = yaml.safe_load(file) for key in {'settings', 'actions'}: if key in config: settings[key].update(config[key]) self.cfg = settings['settings'] self.actions = settings['actions'] if __name__ == "__main__": cm = ClipboardManager() args = docopt(__doc__, version='0.4') if args['--quiet']: cm.cfg['notify'] = False if args['--daemon']: cm.daemon() elif (args['--show'] and not args['--actions']): if args['']: cm.copy_item(args['']) else: cm.show_items(cm.persist if args['--persistent'] else cm.ring) elif (args['--show'] and args['--actions']): if args['']: cm.do_action(args['']) else: cm.show_items(cm.actions) elif args['--add']: cm.persistent_add() elif args['--remove']: cm.persistent_remove() elif args['--edit']: cm.persistent_edit() exit(0)