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.
@@ -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()