308 lines
9.9 KiB
Python
Executable File
308 lines
9.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Rofi clipboard manager
|
|
Usage:
|
|
roficlip.py --daemon [-q | --quiet]
|
|
roficlip.py --show [--persistent | --actions] [-q | --quiet] [<item>]
|
|
roficlip.py --add [-q | --quiet ]
|
|
roficlip.py --remove [-q | --quiet]
|
|
roficlip.py --edit
|
|
roficlip.py (-h | --help)
|
|
roficlip.py (-v | --version)
|
|
|
|
Arguments:
|
|
<item> 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['<item>']:
|
|
cm.copy_item(args['<item>'])
|
|
else:
|
|
cm.show_items(cm.persist if args['--persistent'] else cm.ring)
|
|
elif (args['--show'] and args['--actions']):
|
|
if args['<item>']:
|
|
cm.do_action(args['<item>'])
|
|
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)
|