-
Notifications
You must be signed in to change notification settings - Fork 3
/
Copy pathroficlip.py
executable file
·358 lines (326 loc) · 12 KB
/
roficlip.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Rofi clipboard manager
Usage:
roficlip.py --daemon [-q | --quiet]
roficlip.py --show [--persistent | --actions] [<item>] [-q | --quiet]
roficlip.py --clear [-q | --quiet]
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 passed by Rofi on second script run.
Used with actions as index for dict.
Commands:
--daemon Run clipboard manager daemon.
--show Show clipboard history.
--persistent Select to show persistent history.
--actions Select to show actions defined in config.
--clear Clear clipboard history.
--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
from html import escape
try:
# https://docs.gtk.org/gtk3/method.Clipboard.clear.html
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
# Used for injecting hidden index for menu rows. Simulate dmenu behavior.
# See rofi-script.5 for details
ROFI_INFO = b'\0info\x1f'.decode('utf-8')
# Used for pango markup
ROFI_MARKUP = b'\0markup-rows\x1ftrue'.decode('utf-8')
ROFI_COMMENT = '<span color="#aaa">#{}</span>'
# Used with fifo as instruction for cleaing clipboard history
CLEAR_CODE = b'\0clear'.decode('utf-8')
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.
Can clear clipboard history by instruction.
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:
if fifo_in.decode('utf-8') == CLEAR_CODE:
self.cb.set_text('', -1)
self.ring = ['']
self.write(self.ring_db, self.ring)
self.notify_send('Clipboard is cleaned')
else:
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 clear_ring(self):
"""
Write to fifo instruction for cleaning clipboard history
"""
with open(self.fifo_path, "w") as file:
file.write(CLEAR_CODE)
file.close()
def copy_item(self, index, items):
"""
Writes to fifo item that should be copied to clipboard.
"""
with open(self.fifo_path, "w") as file:
file.write(items[index])
file.close()
def show_items(self, items):
"""
Format and show contents of specified items dict (for rofi).
"""
print(ROFI_MARKUP) if self.cfg['colored_comments'] else None
for index, clip in enumerate(items):
if args['--actions']:
print(clip)
else:
# Replace newline characters for joining string
clip = clip.replace('\n', self.cfg['newline_char'])
if (self.cfg['colored_comments'] or self.cfg['show_comments_first']) and '#' in clip:
# Save index of last '#'
idx = clip.rfind('#')
body = escape(clip[:idx]) if self.cfg['colored_comments'] else clip[:idx]
comment = ROFI_COMMENT.format(
escape(clip[idx+1:])
) if self.cfg['colored_comments'] else '#' + clip[idx+1:]
if args['--persistent'] and self.cfg['show_comments_first']:
# Move text after last '#' to beginning of string
clip = '{} {}'.format(comment, body)
else:
clip = '{} {}'.format(body, comment)
print('{}{}{}'.format(clip, ROFI_INFO, index))
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', default='vi')
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])
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, item):
"""
Run selected action on clipboard contents.
"""
clip = self.cb.wait_for_text()
params = self.actions[item].split(' ')
while '%s' in params:
params[params.index('%s')] = clip
proc = Popen(params, stdout=DEVNULL, stderr=DEVNULL)
ret = proc.wait()
if ret == 0:
self.notify_send(item)
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,
'newline_char': '¬',
'notify': True,
'notify_timeout': 1,
'show_comments_first': False,
'colored_comments': 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.5')
if args['--quiet']:
cm.cfg['notify'] = False
if args['--daemon']:
cm.daemon()
elif args['--clear']:
cm.clear_ring()
elif args['--add']:
cm.persistent_add()
elif args['--remove']:
cm.persistent_remove()
elif args['--edit']:
cm.persistent_edit()
elif args['--show']:
# Parse variables passed from rofi. See rofi-script.5 for details.
# We get index from selected row here.
if os.getenv('ROFI_INFO') is not None:
index = int(os.getenv('ROFI_INFO'))
elif args['<item>'] is not None:
index = args['<item>']
else:
index = None
# Show contents on first run
if index is None:
cm.show_items(cm.actions if args['--actions'] else cm.persist if args['--persistent'] else cm.ring)
# Do actions on second run
else:
if args['--actions']:
cm.do_action(index)
else:
cm.copy_item(index, cm.persist if args['--persistent'] else cm.ring)
exit(0)