bookmarks-curses 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bookmarks_curses/__init__.py +1 -0
- bookmarks_curses/__main__.py +381 -0
- bookmarks_curses/curses_utils.py +357 -0
- bookmarks_curses/db.py +439 -0
- bookmarks_curses/import_diigo.py +166 -0
- bookmarks_curses/utils.py +95 -0
- bookmarks_curses-0.3.0.dist-info/METADATA +57 -0
- bookmarks_curses-0.3.0.dist-info/RECORD +11 -0
- bookmarks_curses-0.3.0.dist-info/WHEEL +4 -0
- bookmarks_curses-0.3.0.dist-info/entry_points.txt +2 -0
- bookmarks_curses-0.3.0.dist-info/licenses/LICENSE.txt +339 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.3.0'
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import curses
|
|
2
|
+
import curses.ascii
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import webbrowser
|
|
7
|
+
from functools import partial
|
|
8
|
+
from signal import SIGINT, signal
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .curses_utils import App, List, ask_delete, input_search, win_addstr, win_help
|
|
12
|
+
from .db import (
|
|
13
|
+
EDIT,
|
|
14
|
+
SORT,
|
|
15
|
+
SORTED,
|
|
16
|
+
Db,
|
|
17
|
+
Record,
|
|
18
|
+
is_sorted,
|
|
19
|
+
record2str,
|
|
20
|
+
sqlite_db,
|
|
21
|
+
)
|
|
22
|
+
from .import_diigo import import_html
|
|
23
|
+
from .utils import (
|
|
24
|
+
FilterString,
|
|
25
|
+
RowString,
|
|
26
|
+
chunkstring,
|
|
27
|
+
input_file,
|
|
28
|
+
int2time,
|
|
29
|
+
str2clipboard,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
HELP = [
|
|
33
|
+
("h", "This help screen"),
|
|
34
|
+
("q, Esc", "Quit the program"),
|
|
35
|
+
("j, Down", "Move selection down"),
|
|
36
|
+
("k, Up", "Move selection up"),
|
|
37
|
+
("PgUp", "Page up"),
|
|
38
|
+
("PgDown", "Page down"),
|
|
39
|
+
("g, Home", "Move to first item"),
|
|
40
|
+
("G, End", "Move to last item"),
|
|
41
|
+
("Alt-{m,c,t,u}", "Sort by modtime, created, title, URL"),
|
|
42
|
+
("Delete", "Delete current record"),
|
|
43
|
+
("Insert", "Insert record"),
|
|
44
|
+
("e", "Edit current record"),
|
|
45
|
+
("L", "Launch URL"),
|
|
46
|
+
("I", "Import html (Diigo export Chrome)"),
|
|
47
|
+
("s", "Search records"),
|
|
48
|
+
("Ctrl-L", "Copy URL to clipboard"),
|
|
49
|
+
("Ctrl-T", "Copy Title to clipboard"),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
HEADER_KEYS = ('t', 'm', 'c', 'g')
|
|
53
|
+
HEADER: dict[str, str] = dict(zip(HEADER_KEYS, ('Title', 'ModTime', 'Created', 'Tags')))
|
|
54
|
+
|
|
55
|
+
SORT_UP = '\u2191'
|
|
56
|
+
SORT_DOWN = '\u2193'
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Main(App): # pylint: disable=too-many-instance-attributes,too-many-public-methods
|
|
60
|
+
def __init__(self, db: Db, screen):
|
|
61
|
+
super().__init__(screen)
|
|
62
|
+
|
|
63
|
+
self.db = db
|
|
64
|
+
|
|
65
|
+
curses.init_pair(1, curses.COLOR_CYAN, curses.COLOR_BLACK)
|
|
66
|
+
curses.init_pair(2, curses.COLOR_RED, curses.COLOR_BLACK)
|
|
67
|
+
|
|
68
|
+
self.records: list[str] = [] # uuid
|
|
69
|
+
self.filter = FilterString()
|
|
70
|
+
self.sort(SORT.LAST_MOD)
|
|
71
|
+
|
|
72
|
+
# title, last_mod, created, tags
|
|
73
|
+
self.row_string = RowString(70, 19, 19, 0)
|
|
74
|
+
|
|
75
|
+
self.create_windows()
|
|
76
|
+
|
|
77
|
+
def sort(self, sortby: SORT):
|
|
78
|
+
self.sortedby = sortby
|
|
79
|
+
self.records = [r.uuid for r in self.db.sort(sortby) if self.filter_record(r)]
|
|
80
|
+
|
|
81
|
+
def sort2(self, sortby: SORT):
|
|
82
|
+
idx = self.win.idx
|
|
83
|
+
uuid = None
|
|
84
|
+
if idx < self.records_len():
|
|
85
|
+
uuid = self.records[idx]
|
|
86
|
+
self.sort(sortby)
|
|
87
|
+
# find new index of the record
|
|
88
|
+
if uuid:
|
|
89
|
+
g = (i for i, x in enumerate(self.records) if x == uuid) # generator
|
|
90
|
+
idx2 = next(g, 0)
|
|
91
|
+
else:
|
|
92
|
+
idx2 = 0
|
|
93
|
+
self.win.idx = idx2
|
|
94
|
+
self.refresh_all()
|
|
95
|
+
|
|
96
|
+
def create_windows(self):
|
|
97
|
+
'''
|
|
98
|
+
<project> v...file...time...
|
|
99
|
+
Search: ...
|
|
100
|
+
... header ...
|
|
101
|
+
records ... | record |
|
|
102
|
+
2/3 1/3
|
|
103
|
+
---------------------
|
|
104
|
+
status ...
|
|
105
|
+
'''
|
|
106
|
+
maxy, maxx = self.screen_size
|
|
107
|
+
|
|
108
|
+
rows, cols = (maxy - 6, maxx)
|
|
109
|
+
cols2 = min(cols // 3, 35)
|
|
110
|
+
cols1 = cols - cols2
|
|
111
|
+
if no_win2 := cols1 < sum(self.row_string.widths[:2]):
|
|
112
|
+
cols1 = cols
|
|
113
|
+
|
|
114
|
+
prompt = self.prompt_search = ' Search: '
|
|
115
|
+
len_ = len(prompt)
|
|
116
|
+
self.win_search = self.screen.derwin(1, maxx - len_, 1, len_)
|
|
117
|
+
|
|
118
|
+
win = self.screen.derwin(rows, cols1 - 3, 4, 2)
|
|
119
|
+
self.win = List(win, self, current_color=curses.color_pair(1) | curses.A_BOLD)
|
|
120
|
+
|
|
121
|
+
if no_win2:
|
|
122
|
+
self.win2 = None
|
|
123
|
+
else:
|
|
124
|
+
self.win2 = self.screen.derwin(maxy - 3, cols2, 2, cols1)
|
|
125
|
+
|
|
126
|
+
# status
|
|
127
|
+
self.win3 = self.screen.derwin(1, maxx, maxy - 1, 0)
|
|
128
|
+
|
|
129
|
+
def refresh_win_deps(self):
|
|
130
|
+
if not self.win2:
|
|
131
|
+
return
|
|
132
|
+
rows, cols = self.win2.getmaxyx()
|
|
133
|
+
rows -= 2 # -borders
|
|
134
|
+
cols -= 2 # -borders
|
|
135
|
+
win = self.win2.derwin(rows, cols, 1, 1)
|
|
136
|
+
win.erase()
|
|
137
|
+
idx = self.win.idx
|
|
138
|
+
if idx < len(self.records):
|
|
139
|
+
uuid = self.records[idx]
|
|
140
|
+
r = self.db.get_by_uuid(uuid)
|
|
141
|
+
record2win(r, win)
|
|
142
|
+
self.win2.refresh()
|
|
143
|
+
|
|
144
|
+
def del_record(self, i: int):
|
|
145
|
+
if not (uuid := self.get_record(i)):
|
|
146
|
+
return
|
|
147
|
+
if ask_delete(self.screen, color=curses.color_pair(2)):
|
|
148
|
+
if self.db.mark_del(uuid, True):
|
|
149
|
+
del self.records[i]
|
|
150
|
+
else:
|
|
151
|
+
self.status(f'error: {self.db.error}')
|
|
152
|
+
self.win.refresh()
|
|
153
|
+
|
|
154
|
+
def get_record(self, i: int) -> str | None:
|
|
155
|
+
len_ = len(self.records)
|
|
156
|
+
if i >= len_:
|
|
157
|
+
return None
|
|
158
|
+
return self.records[i]
|
|
159
|
+
|
|
160
|
+
def get_record_str(self, i: int) -> str:
|
|
161
|
+
if (uuid := self.get_record(i)) and (r := self.db.get_by_uuid(uuid)):
|
|
162
|
+
return self.row_string.value(r.title, int2time(r.last_mod), int2time(r.created), r.tags)
|
|
163
|
+
return ''
|
|
164
|
+
|
|
165
|
+
def records_len(self) -> int:
|
|
166
|
+
return len(self.records)
|
|
167
|
+
|
|
168
|
+
def filter_record(self, record):
|
|
169
|
+
return self.filter.found(record.title, record.tags)
|
|
170
|
+
|
|
171
|
+
def create_header(self):
|
|
172
|
+
headers = []
|
|
173
|
+
for key2 in HEADER_KEYS:
|
|
174
|
+
title = HEADER[key2]
|
|
175
|
+
match is_sorted(key2, self.sortedby):
|
|
176
|
+
case SORTED.ASC:
|
|
177
|
+
headers.append(f'{title}({SORT_DOWN}):')
|
|
178
|
+
case SORTED.DESC:
|
|
179
|
+
headers.append(f'{title}({SORT_UP}):')
|
|
180
|
+
case SORTED.NO:
|
|
181
|
+
headers.append(f'{title}:')
|
|
182
|
+
return self.row_string.value(*headers)
|
|
183
|
+
|
|
184
|
+
def refresh_all(self):
|
|
185
|
+
self.screen.clear()
|
|
186
|
+
|
|
187
|
+
s = f' bookmarks-curses v{__version__} (h - Help)'
|
|
188
|
+
win_addstr(self.screen, 0, 0, s)
|
|
189
|
+
|
|
190
|
+
win_addstr(self.screen, 1, 0, self.prompt_search)
|
|
191
|
+
self.screen.refresh()
|
|
192
|
+
|
|
193
|
+
self.win_search.erase()
|
|
194
|
+
win_addstr(self.win_search, 0, 0, self.filter.filter_string)
|
|
195
|
+
self.win_search.refresh()
|
|
196
|
+
|
|
197
|
+
maxy, maxx = self.screen_size
|
|
198
|
+
win = self.screen.derwin(maxy - 3, maxx, 2, 0)
|
|
199
|
+
win.erase()
|
|
200
|
+
win_addstr(win, 1, 2, self.create_header())
|
|
201
|
+
win.box()
|
|
202
|
+
win.refresh()
|
|
203
|
+
|
|
204
|
+
self.win.refresh()
|
|
205
|
+
|
|
206
|
+
if self.win2:
|
|
207
|
+
self.win2.erase()
|
|
208
|
+
self.win2.border(0, 0, 0, 0, curses.ACS_TTEE, 0, curses.ACS_BTEE, 0)
|
|
209
|
+
self.win2.refresh()
|
|
210
|
+
|
|
211
|
+
self.refresh_win_deps()
|
|
212
|
+
|
|
213
|
+
def run(self):
|
|
214
|
+
self.refresh_all()
|
|
215
|
+
self.input_loop()
|
|
216
|
+
|
|
217
|
+
def handle_alt_key(self, ch: int):
|
|
218
|
+
if (ch2 := chr(ch)) in SORT:
|
|
219
|
+
self.sort2(SORT(ch2))
|
|
220
|
+
|
|
221
|
+
def status(self, s: str):
|
|
222
|
+
win = self.win3
|
|
223
|
+
win.erase()
|
|
224
|
+
win_addstr(win, 0, 1, s)
|
|
225
|
+
win.refresh()
|
|
226
|
+
|
|
227
|
+
def search(self):
|
|
228
|
+
ok, s = input_search(self, self.prompt_search.lstrip())
|
|
229
|
+
if ok:
|
|
230
|
+
self.filter.set(s)
|
|
231
|
+
win_addstr(self.win_search, 0, 0, self.filter.filter_string)
|
|
232
|
+
self.sort2(self.sortedby)
|
|
233
|
+
|
|
234
|
+
def run_url(self):
|
|
235
|
+
if (uuid := self.get_record(self.win.idx)) and (r := self.db.get_by_uuid(uuid)) and r.url:
|
|
236
|
+
pass
|
|
237
|
+
else:
|
|
238
|
+
return
|
|
239
|
+
try:
|
|
240
|
+
if shutil.which("xdg-open"):
|
|
241
|
+
subprocess.run(['xdg-open', r.url], check=False)
|
|
242
|
+
else:
|
|
243
|
+
webbrowser.open(r.url)
|
|
244
|
+
except ImportError:
|
|
245
|
+
self.status(f'Could not load python module "webbrowser" for {r.url=}')
|
|
246
|
+
|
|
247
|
+
def url2clipboard(self):
|
|
248
|
+
if (uuid := self.get_record(self.win.idx)) and (r := self.db.get_by_uuid(uuid)):
|
|
249
|
+
pass
|
|
250
|
+
else:
|
|
251
|
+
return
|
|
252
|
+
if r.url:
|
|
253
|
+
str2clipboard(r.url)
|
|
254
|
+
self.status('URL copied to clipboard')
|
|
255
|
+
else:
|
|
256
|
+
self.status('URL is empty')
|
|
257
|
+
|
|
258
|
+
def title2clipboard(self):
|
|
259
|
+
if (uuid := self.get_record(self.win.idx)) and (r := self.db.get_by_uuid(uuid)):
|
|
260
|
+
pass
|
|
261
|
+
else:
|
|
262
|
+
return
|
|
263
|
+
if r.title:
|
|
264
|
+
str2clipboard(r.title)
|
|
265
|
+
self.status('Title copied to clipboard')
|
|
266
|
+
else:
|
|
267
|
+
self.status('Title is empty')
|
|
268
|
+
|
|
269
|
+
def input_loop(self): # pylint: disable=too-many-branches,too-many-statements
|
|
270
|
+
for char_ord in self.getch():
|
|
271
|
+
char = chr(char_ord)
|
|
272
|
+
|
|
273
|
+
if char_ord == curses.KEY_DC: # delete
|
|
274
|
+
self.del_record(self.win.idx)
|
|
275
|
+
elif char_ord == curses.KEY_IC: # insert
|
|
276
|
+
self.insert_record(self.win.idx)
|
|
277
|
+
elif self.win.handle_input(char_ord):
|
|
278
|
+
pass
|
|
279
|
+
elif char == 'e':
|
|
280
|
+
self.edit_record(self.win.idx) # not using curses
|
|
281
|
+
elif char == 's':
|
|
282
|
+
self.filter.set()
|
|
283
|
+
self.search()
|
|
284
|
+
elif char == 'L':
|
|
285
|
+
self.run_url()
|
|
286
|
+
elif char == 'I':
|
|
287
|
+
self.import_html()
|
|
288
|
+
elif char.upper() == 'H': # Print help screen
|
|
289
|
+
win_help(self.win.win, HELP)
|
|
290
|
+
self.refresh_all()
|
|
291
|
+
elif char_ord == 12: # ^L
|
|
292
|
+
self.url2clipboard()
|
|
293
|
+
elif char_ord == 20: # ^T
|
|
294
|
+
self.title2clipboard()
|
|
295
|
+
else:
|
|
296
|
+
name = curses.keyname(char_ord).decode('utf-8')
|
|
297
|
+
self.status(f'{char_ord=}, {name=}')
|
|
298
|
+
|
|
299
|
+
def edit_record(self, i: int):
|
|
300
|
+
if (uuid := self.get_record(i)) and (r := self.db.get_by_uuid(uuid)):
|
|
301
|
+
pass
|
|
302
|
+
else:
|
|
303
|
+
return
|
|
304
|
+
curses.endwin()
|
|
305
|
+
match self.db.edit_record(r):
|
|
306
|
+
case EDIT.NONE:
|
|
307
|
+
self.screen.refresh()
|
|
308
|
+
case EDIT.OK1:
|
|
309
|
+
self.win.refresh()
|
|
310
|
+
case EDIT.OK2:
|
|
311
|
+
self.screen.refresh()
|
|
312
|
+
self.sort2(self.sortedby)
|
|
313
|
+
case _:
|
|
314
|
+
self.screen.refresh()
|
|
315
|
+
self.status(f'error: {self.db.error}')
|
|
316
|
+
|
|
317
|
+
def insert_record(self, i: int):
|
|
318
|
+
curses.endwin()
|
|
319
|
+
r = Record.create()
|
|
320
|
+
match self.db.insert_record(r):
|
|
321
|
+
case EDIT.NONE:
|
|
322
|
+
self.screen.refresh()
|
|
323
|
+
case EDIT.OK1:
|
|
324
|
+
self.records.insert(i, r.uuid)
|
|
325
|
+
self.win.refresh()
|
|
326
|
+
case EDIT.OK2:
|
|
327
|
+
self.records.insert(i, r.uuid)
|
|
328
|
+
self.screen.refresh()
|
|
329
|
+
self.sort2(self.sortedby)
|
|
330
|
+
case _:
|
|
331
|
+
self.screen.refresh()
|
|
332
|
+
self.status(f'error: {self.db.error}')
|
|
333
|
+
|
|
334
|
+
def import_html(self):
|
|
335
|
+
curses.endwin()
|
|
336
|
+
old = signal(SIGINT, self.orig_sigint)
|
|
337
|
+
try:
|
|
338
|
+
res = import_html(self.db)
|
|
339
|
+
finally:
|
|
340
|
+
signal(SIGINT, old)
|
|
341
|
+
self.sort(self.sortedby)
|
|
342
|
+
self.win.refresh()
|
|
343
|
+
if res.ok:
|
|
344
|
+
self.status(f'added: {res.added}, skipped: {res.skipped}')
|
|
345
|
+
else:
|
|
346
|
+
self.status(f'error: {res.line}: {res.error}')
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def record2win(r: Record | None, win):
|
|
350
|
+
if not r:
|
|
351
|
+
win.erase()
|
|
352
|
+
return
|
|
353
|
+
rows, cols = win.getmaxyx()
|
|
354
|
+
row = -1
|
|
355
|
+
for line in record2str(r).splitlines():
|
|
356
|
+
for s in chunkstring(line, cols):
|
|
357
|
+
row += 1
|
|
358
|
+
if not row < rows:
|
|
359
|
+
return
|
|
360
|
+
win_addstr(win, row, 0, s)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def main2(db, screen):
|
|
364
|
+
app = Main(db, screen)
|
|
365
|
+
app.run()
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def main():
|
|
369
|
+
try:
|
|
370
|
+
fpath = input_file('sqlite file: ')
|
|
371
|
+
if not os.path.exists(fpath):
|
|
372
|
+
print(f'New sqlite file: {fpath}')
|
|
373
|
+
except KeyboardInterrupt:
|
|
374
|
+
return
|
|
375
|
+
with sqlite_db(fpath) as db:
|
|
376
|
+
main2_ = partial(main2, db)
|
|
377
|
+
curses.wrapper(main2_)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
if __name__ == '__main__':
|
|
381
|
+
main()
|