1#!/usr/local/bin/python3.8
2# vim:fileencoding=utf-8
3# License: GPL v3 Copyright: 2013, Kovid Goyal <kovid at kovidgoyal.net>
4
5
6import json
7from operator import itemgetter
8from qt.core import (
9    QAction, QComboBox, QGridLayout, QHBoxLayout, QIcon, QInputDialog,
10    QItemSelectionModel, QLabel, QListWidget, QListWidgetItem, QPushButton, Qt,
11    QWidget, pyqtSignal
12)
13
14from calibre.gui2 import choose_files, choose_save_file
15from calibre.gui2.dialogs.confirm_delete import confirm
16from calibre.gui2.viewer.shortcuts import get_shortcut_for
17from calibre.gui2.viewer.web_view import vprefs
18from calibre.utils.date import EPOCH, utcnow
19from calibre.utils.icu import primary_sort_key
20
21
22class BookmarksList(QListWidget):
23
24    changed = pyqtSignal()
25    bookmark_activated = pyqtSignal(object)
26
27    def __init__(self, parent=None):
28        QListWidget.__init__(self, parent)
29        self.setAlternatingRowColors(True)
30        self.setStyleSheet('QListView::item { padding: 0.5ex }')
31        self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
32        self.ac_edit = ac = QAction(QIcon(I('edit_input.png')), _('Rename this bookmark'), self)
33        self.addAction(ac)
34        self.ac_delete = ac = QAction(QIcon(I('trash.png')), _('Remove this bookmark'), self)
35        self.addAction(ac)
36
37    @property
38    def current_non_removed_item(self):
39        ans = self.currentItem()
40        if ans is not None:
41            bm = ans.data(Qt.ItemDataRole.UserRole)
42            if not bm.get('removed'):
43                return ans
44
45    def keyPressEvent(self, ev):
46        if ev.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
47            i = self.current_non_removed_item
48            if i is not None:
49                self.bookmark_activated.emit(i)
50                ev.accept()
51                return
52        if ev.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace):
53            i = self.current_non_removed_item
54            if i is not None:
55                self.ac_delete.trigger()
56                ev.accept()
57                return
58        return QListWidget.keyPressEvent(self, ev)
59
60    def activate_related_bookmark(self, delta=1):
61        if not self.count():
62            return
63        items = [self.item(r) for r in range(self.count())]
64        row = self.currentRow()
65        current_item = items[row]
66        items = [i for i in items if not i.isHidden()]
67        count = len(items)
68        if not count:
69            return
70        row = items.index(current_item)
71        nrow = (row + delta + count) % count
72        self.setCurrentItem(items[nrow])
73        self.bookmark_activated.emit(self.currentItem())
74
75    def next_bookmark(self):
76        self.activate_related_bookmark()
77
78    def previous_bookmark(self):
79        self.activate_related_bookmark(-1)
80
81
82class BookmarkManager(QWidget):
83
84    edited = pyqtSignal(object)
85    activated = pyqtSignal(object)
86    create_requested = pyqtSignal()
87    toggle_requested = pyqtSignal()
88
89    def __init__(self, parent):
90        QWidget.__init__(self, parent)
91        self.l = l = QGridLayout(self)
92        l.setContentsMargins(0, 0, 0, 0)
93        self.setLayout(l)
94        self.toc = parent.toc
95
96        self.bookmarks_list = bl = BookmarksList(self)
97        bl.itemChanged.connect(self.item_changed)
98        l.addWidget(bl, 0, 0, 1, -1)
99        bl.itemClicked.connect(self.item_activated)
100        bl.bookmark_activated.connect(self.item_activated)
101        bl.changed.connect(lambda : self.edited.emit(self.get_bookmarks()))
102        bl.ac_edit.triggered.connect(self.edit_bookmark)
103        bl.ac_delete.triggered.connect(self.delete_bookmark)
104
105        self.la = la = QLabel(_(
106            'Double click to edit the bookmarks'))
107        la.setWordWrap(True)
108        l.addWidget(la, l.rowCount(), 0, 1, -1)
109
110        self.button_new = b = QPushButton(QIcon(I('bookmarks.png')), _('&New'), self)
111        b.clicked.connect(self.create_requested)
112        b.setToolTip(_('Create a new bookmark at the current location'))
113        l.addWidget(b)
114
115        self.button_delete = b = QPushButton(QIcon(I('trash.png')), _('&Remove'), self)
116        b.setToolTip(_('Remove the currently selected bookmark'))
117        b.clicked.connect(self.delete_bookmark)
118        l.addWidget(b, l.rowCount() - 1, 1)
119
120        self.button_prev = b = QPushButton(QIcon(I('back.png')), _('Pre&vious'), self)
121        b.clicked.connect(self.bookmarks_list.previous_bookmark)
122        l.addWidget(b)
123
124        self.button_next = b = QPushButton(QIcon(I('forward.png')), _('Nex&t'), self)
125        b.clicked.connect(self.bookmarks_list.next_bookmark)
126        l.addWidget(b, l.rowCount() - 1, 1)
127
128        la = QLabel(_('&Sort by:'))
129        self.sort_by = sb = QComboBox(self)
130        la.setBuddy(sb)
131        sb.addItem(_('Title'), 'title')
132        sb.addItem(_('Position in book'), 'pos')
133        sb.addItem(_('Date'), 'timestamp')
134        sb.setToolTip(_('Change how the bookmarks are sorted'))
135        i = sb.findData(vprefs['bookmarks_sort'])
136        if i > -1:
137            sb.setCurrentIndex(i)
138        h = QHBoxLayout()
139        h.addWidget(la), h.addWidget(sb, 10)
140        l.addLayout(h, l.rowCount(), 0, 1, 2)
141        sb.currentIndexChanged.connect(self.sort_by_changed)
142
143        self.button_export = b = QPushButton(_('E&xport'), self)
144        b.clicked.connect(self.export_bookmarks)
145        l.addWidget(b, l.rowCount(), 0)
146
147        self.button_import = b = QPushButton(_('&Import'), self)
148        b.clicked.connect(self.import_bookmarks)
149        l.addWidget(b, l.rowCount() - 1, 1)
150
151    def item_activated(self, item):
152        bm = self.item_to_bm(item)
153        self.activated.emit(bm['pos'])
154
155    @property
156    def current_sort_by(self):
157        return self.sort_by.currentData()
158
159    def sort_by_changed(self):
160        vprefs['bookmarks_sort'] = self.current_sort_by
161        self.set_bookmarks(self.get_bookmarks())
162
163    def set_bookmarks(self, bookmarks=()):
164        csb = self.current_sort_by
165        if csb in ('name', 'title'):
166            sk = lambda x: primary_sort_key(x['title'])
167        elif csb == 'timestamp':
168            sk = itemgetter('timestamp')
169        else:
170            from calibre.ebooks.epub.cfi.parse import cfi_sort_key
171            defval = cfi_sort_key('/99999999')
172
173            def pos_key(b):
174                if b.get('pos_type') == 'epubcfi':
175                    return cfi_sort_key(b['pos'], only_path=False)
176                return defval
177            sk = pos_key
178
179        bookmarks = sorted(bookmarks, key=sk)
180        current_bookmark_id = self.current_bookmark_id
181        self.bookmarks_list.clear()
182        for bm in bookmarks:
183            i = QListWidgetItem(bm['title'])
184            i.setData(Qt.ItemDataRole.ToolTipRole, bm['title'])
185            i.setData(Qt.ItemDataRole.UserRole, self.bm_to_item(bm))
186            i.setFlags(i.flags() | Qt.ItemFlag.ItemIsEditable)
187            self.bookmarks_list.addItem(i)
188            if bm.get('removed'):
189                i.setHidden(True)
190        for i in range(self.bookmarks_list.count()):
191            item = self.bookmarks_list.item(i)
192            if not item.isHidden():
193                self.bookmarks_list.setCurrentItem(item, QItemSelectionModel.SelectionFlag.ClearAndSelect)
194                break
195        if current_bookmark_id is not None:
196            self.current_bookmark_id = current_bookmark_id
197
198    @property
199    def current_bookmark_id(self):
200        item = self.bookmarks_list.currentItem()
201        if item is not None:
202            return item.data(Qt.ItemDataRole.DisplayRole)
203
204    @current_bookmark_id.setter
205    def current_bookmark_id(self, val):
206        for i, q in enumerate(self):
207            if q['title'] == val:
208                item = self.bookmarks_list.item(i)
209                self.bookmarks_list.setCurrentItem(item, QItemSelectionModel.SelectionFlag.ClearAndSelect)
210                self.bookmarks_list.scrollToItem(item)
211
212    def set_current_bookmark(self, bm):
213        for i, q in enumerate(self):
214            if bm == q:
215                l = self.bookmarks_list
216                item = l.item(i)
217                l.setCurrentItem(item, QItemSelectionModel.SelectionFlag.ClearAndSelect)
218                l.scrollToItem(item)
219
220    def __iter__(self):
221        for i in range(self.bookmarks_list.count()):
222            yield self.item_to_bm(self.bookmarks_list.item(i))
223
224    def uniqify_bookmark_title(self, base):
225        remove = []
226        for i in range(self.bookmarks_list.count()):
227            item = self.bookmarks_list.item(i)
228            bm = item.data(Qt.ItemDataRole.UserRole)
229            if bm.get('removed') and bm['title'] == base:
230                remove.append(i)
231        for i in reversed(remove):
232            self.bookmarks_list.takeItem(i)
233        all_titles = {bm['title'] for bm in self.get_bookmarks()}
234        c = 0
235        q = base
236        while q in all_titles:
237            c += 1
238            q = '{} #{}'.format(base, c)
239        return q
240
241    def item_changed(self, item):
242        self.bookmarks_list.blockSignals(True)
243        title = str(item.data(Qt.ItemDataRole.DisplayRole)) or _('Unknown')
244        title = self.uniqify_bookmark_title(title)
245        item.setData(Qt.ItemDataRole.DisplayRole, title)
246        item.setData(Qt.ItemDataRole.ToolTipRole, title)
247        bm = item.data(Qt.ItemDataRole.UserRole)
248        bm['title'] = title
249        bm['timestamp'] = utcnow().isoformat()
250        item.setData(Qt.ItemDataRole.UserRole, bm)
251        self.bookmarks_list.blockSignals(False)
252        self.edited.emit(self.get_bookmarks())
253
254    def delete_bookmark(self):
255        item = self.bookmarks_list.current_non_removed_item
256        if item is not None:
257            bm = item.data(Qt.ItemDataRole.UserRole)
258            if confirm(
259                _('Are you sure you want to delete the bookmark: {0}?').format(bm['title']),
260                'delete-bookmark-from-viewer', parent=self, config_set=vprefs
261            ):
262                bm['removed'] = True
263                bm['timestamp'] = utcnow().isoformat()
264                self.bookmarks_list.blockSignals(True)
265                item.setData(Qt.ItemDataRole.UserRole, bm)
266                self.bookmarks_list.blockSignals(False)
267                item.setHidden(True)
268                self.edited.emit(self.get_bookmarks())
269
270    def edit_bookmark(self):
271        item = self.bookmarks_list.current_non_removed_item
272        if item is not None:
273            self.bookmarks_list.editItem(item)
274
275    def bm_to_item(self, bm):
276        return bm.copy()
277
278    def item_to_bm(self, item):
279        return item.data(Qt.ItemDataRole.UserRole).copy()
280
281    def get_bookmarks(self):
282        return list(self)
283
284    def export_bookmarks(self):
285        filename = choose_save_file(
286            self, 'export-viewer-bookmarks', _('Export bookmarks'),
287            filters=[(_('Saved bookmarks'), ['calibre-bookmarks'])], all_files=False, initial_filename='bookmarks.calibre-bookmarks')
288        if filename:
289            bm = [x for x in self.get_bookmarks() if not x.get('removed')]
290            data = json.dumps({'type': 'bookmarks', 'entries': bm}, indent=True)
291            if not isinstance(data, bytes):
292                data = data.encode('utf-8')
293            with lopen(filename, 'wb') as fileobj:
294                fileobj.write(data)
295
296    def import_bookmarks(self):
297        files = choose_files(self, 'export-viewer-bookmarks', _('Import bookmarks'),
298            filters=[(_('Saved bookmarks'), ['calibre-bookmarks'])], all_files=False, select_only_single_file=True)
299        if not files:
300            return
301        filename = files[0]
302
303        imported = None
304        with lopen(filename, 'rb') as fileobj:
305            imported = json.load(fileobj)
306
307        def import_old_bookmarks(imported):
308            try:
309                for bm in imported:
310                    if 'title' not in bm:
311                        return
312            except Exception:
313                return
314
315            bookmarks = self.get_bookmarks()
316            for bm in imported:
317                if bm['title'] == 'calibre_current_page_bookmark':
318                    continue
319                epubcfi = 'epubcfi(/{}/{})'.format((bm['spine'] + 1) * 2, bm['pos'].lstrip('/'))
320                q = {'pos_type': 'epubcfi', 'pos': epubcfi, 'timestamp': EPOCH.isoformat(), 'title': bm['title']}
321                if q not in bookmarks:
322                    bookmarks.append(q)
323            self.set_bookmarks(bookmarks)
324            self.edited.emit(self.get_bookmarks())
325
326        def import_current_bookmarks(imported):
327            if imported.get('type') != 'bookmarks':
328                return
329            bookmarks = self.get_bookmarks()
330            for bm in imported['entries']:
331                if bm not in bookmarks:
332                    bookmarks.append(bm)
333            self.set_bookmarks(bookmarks)
334            self.edited.emit(self.get_bookmarks())
335
336        if imported is not None:
337            if isinstance(imported, list):
338                import_old_bookmarks(imported)
339            else:
340                import_current_bookmarks(imported)
341
342    def create_new_bookmark(self, pos_data):
343        base_default_title = self.toc.model().title_for_current_node or _('Bookmark')
344        all_titles = {bm['title'] for bm in self.get_bookmarks()}
345        c = 0
346        while True:
347            c += 1
348            default_title = '{} #{}'.format(base_default_title, c)
349            if default_title not in all_titles:
350                break
351
352        title, ok = QInputDialog.getText(self, _('Add bookmark'),
353                _('Enter title for bookmark:'), text=pos_data.get('selected_text') or default_title)
354        title = str(title).strip()
355        if not ok or not title:
356            return
357        title = self.uniqify_bookmark_title(title)
358        cfi = (pos_data.get('selection_bounds') or {}).get('start') or pos_data['cfi']
359        bm = {
360            'title': title,
361            'pos_type': 'epubcfi',
362            'pos': cfi,
363            'timestamp': utcnow().isoformat(),
364        }
365        bookmarks = self.get_bookmarks()
366        bookmarks.append(bm)
367        self.set_bookmarks(bookmarks)
368        self.set_current_bookmark(bm)
369        self.edited.emit(bookmarks)
370
371    def keyPressEvent(self, ev):
372        sc = get_shortcut_for(self, ev)
373        if ev.key() == Qt.Key.Key_Escape or sc == 'toggle_bookmarks':
374            self.toggle_requested.emit()
375            return
376        if sc == 'new_bookmark':
377            self.create_requested.emit()
378            return
379        return QWidget.keyPressEvent(self, ev)
380