1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2009 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4#
5
6"""
7Module implementing the bookmarks manager.
8"""
9
10import os
11import contextlib
12
13from PyQt5.QtCore import (
14    pyqtSignal, QT_TRANSLATE_NOOP, QObject, QFile, QIODevice, QXmlStreamReader,
15    QDateTime, QFileInfo, QUrl, QCoreApplication
16)
17from PyQt5.QtWidgets import QUndoStack, QUndoCommand, QDialog
18
19from E5Gui import E5MessageBox, E5FileDialog
20
21from .BookmarkNode import BookmarkNode
22
23from Utilities.AutoSaver import AutoSaver
24import Utilities
25
26BOOKMARKBAR = QT_TRANSLATE_NOOP("BookmarksManager", "Bookmarks Bar")
27BOOKMARKMENU = QT_TRANSLATE_NOOP("BookmarksManager", "Bookmarks Menu")
28
29StartRoot = 0
30StartMenu = 1
31StartToolBar = 2
32
33
34class BookmarksManager(QObject):
35    """
36    Class implementing the bookmarks manager.
37
38    @signal entryAdded(BookmarkNode) emitted after a bookmark node has been
39        added
40    @signal entryRemoved(BookmarkNode, int, BookmarkNode) emitted after a
41        bookmark node has been removed
42    @signal entryChanged(BookmarkNode) emitted after a bookmark node has been
43        changed
44    @signal bookmarksSaved() emitted after the bookmarks were saved
45    @signal bookmarksReloaded() emitted after the bookmarks were reloaded
46    """
47    entryAdded = pyqtSignal(BookmarkNode)
48    entryRemoved = pyqtSignal(BookmarkNode, int, BookmarkNode)
49    entryChanged = pyqtSignal(BookmarkNode)
50    bookmarksSaved = pyqtSignal()
51    bookmarksReloaded = pyqtSignal()
52
53    def __init__(self, parent=None):
54        """
55        Constructor
56
57        @param parent reference to the parent object (QObject)
58        """
59        super().__init__(parent)
60
61        self.__saveTimer = AutoSaver(self, self.save)
62        self.entryAdded.connect(self.__saveTimer.changeOccurred)
63        self.entryRemoved.connect(self.__saveTimer.changeOccurred)
64        self.entryChanged.connect(self.__saveTimer.changeOccurred)
65
66        self.__initialize()
67
68    def __initialize(self):
69        """
70        Private method to initialize some data.
71        """
72        self.__loaded = False
73        self.__bookmarkRootNode = None
74        self.__toolbar = None
75        self.__menu = None
76        self.__bookmarksModel = None
77        self.__commands = QUndoStack()
78
79    @classmethod
80    def getFileName(cls):
81        """
82        Class method to get the file name of the bookmark file.
83
84        @return name of the bookmark file (string)
85        """
86        return os.path.join(Utilities.getConfigDir(), "web_browser",
87                            "bookmarks.xbel")
88
89    def close(self):
90        """
91        Public method to close the bookmark manager.
92        """
93        self.__saveTimer.saveIfNeccessary()
94
95    def undoRedoStack(self):
96        """
97        Public method to get a reference to the undo stack.
98
99        @return reference to the undo stack (QUndoStack)
100        """
101        return self.__commands
102
103    def changeExpanded(self):
104        """
105        Public method to handle a change of the expanded state.
106        """
107        self.__saveTimer.changeOccurred()
108
109    def reload(self):
110        """
111        Public method used to initiate a reloading of the bookmarks.
112        """
113        self.__initialize()
114        self.load()
115        self.bookmarksReloaded.emit()
116
117    def load(self):
118        """
119        Public method to load the bookmarks.
120
121        @exception RuntimeError raised to indicate an error loading the
122            bookmarks
123        """
124        if self.__loaded:
125            return
126
127        self.__loaded = True
128
129        bookmarkFile = self.getFileName()
130        if not QFile.exists(bookmarkFile):
131            bookmarkFile = QFile(os.path.join(
132                os.path.dirname(__file__), "DefaultBookmarks.xbel"))
133            bookmarkFile.open(QIODevice.OpenModeFlag.ReadOnly)
134
135        from .XbelReader import XbelReader
136        reader = XbelReader()
137        self.__bookmarkRootNode = reader.read(bookmarkFile)
138        if reader.error() != QXmlStreamReader.Error.NoError:
139            E5MessageBox.warning(
140                None,
141                self.tr("Loading Bookmarks"),
142                self.tr(
143                    """Error when loading bookmarks on line {0},"""
144                    """ column {1}:\n {2}""")
145                .format(reader.lineNumber(),
146                        reader.columnNumber(),
147                        reader.errorString()))
148
149        others = []
150        for index in range(
151                len(self.__bookmarkRootNode.children()) - 1, -1, -1):
152            node = self.__bookmarkRootNode.children()[index]
153            if node.type() == BookmarkNode.Folder:
154                if (
155                    (node.title == self.tr("Toolbar Bookmarks") or
156                     node.title == BOOKMARKBAR) and
157                    self.__toolbar is None
158                ):
159                    node.title = self.tr(BOOKMARKBAR)
160                    self.__toolbar = node
161
162                if (
163                    (node.title == self.tr("Menu") or
164                     node.title == BOOKMARKMENU) and
165                    self.__menu is None
166                ):
167                    node.title = self.tr(BOOKMARKMENU)
168                    self.__menu = node
169            else:
170                others.append(node)
171            self.__bookmarkRootNode.remove(node)
172
173        if len(self.__bookmarkRootNode.children()) > 0:
174            raise RuntimeError("Error loading bookmarks.")
175
176        if self.__toolbar is None:
177            self.__toolbar = BookmarkNode(BookmarkNode.Folder,
178                                          self.__bookmarkRootNode)
179            self.__toolbar.title = self.tr(BOOKMARKBAR)
180        else:
181            self.__bookmarkRootNode.add(self.__toolbar)
182
183        if self.__menu is None:
184            self.__menu = BookmarkNode(BookmarkNode.Folder,
185                                       self.__bookmarkRootNode)
186            self.__menu.title = self.tr(BOOKMARKMENU)
187        else:
188            self.__bookmarkRootNode.add(self.__menu)
189
190        for node in others:
191            self.__menu.add(node)
192
193    def save(self):
194        """
195        Public method to save the bookmarks.
196        """
197        if not self.__loaded:
198            return
199
200        from .XbelWriter import XbelWriter
201        writer = XbelWriter()
202        bookmarkFile = self.getFileName()
203
204        # save root folder titles in English (i.e. not localized)
205        self.__menu.title = BOOKMARKMENU
206        self.__toolbar.title = BOOKMARKBAR
207        if not writer.write(bookmarkFile, self.__bookmarkRootNode):
208            E5MessageBox.warning(
209                None,
210                self.tr("Saving Bookmarks"),
211                self.tr("""Error saving bookmarks to <b>{0}</b>.""")
212                .format(bookmarkFile))
213
214        # restore localized titles
215        self.__menu.title = self.tr(BOOKMARKMENU)
216        self.__toolbar.title = self.tr(BOOKMARKBAR)
217
218        self.bookmarksSaved.emit()
219
220    def addBookmark(self, parent, node, row=-1):
221        """
222        Public method to add a bookmark.
223
224        @param parent reference to the node to add to (BookmarkNode)
225        @param node reference to the node to add (BookmarkNode)
226        @param row row number (integer)
227        """
228        if not self.__loaded:
229            return
230
231        self.setTimestamp(node, BookmarkNode.TsAdded,
232                          QDateTime.currentDateTime())
233
234        command = InsertBookmarksCommand(self, parent, node, row)
235        self.__commands.push(command)
236
237    def removeBookmark(self, node):
238        """
239        Public method to remove a bookmark.
240
241        @param node reference to the node to be removed (BookmarkNode)
242        """
243        if not self.__loaded:
244            return
245
246        parent = node.parent()
247        row = parent.children().index(node)
248        command = RemoveBookmarksCommand(self, parent, row)
249        self.__commands.push(command)
250
251    def setTitle(self, node, newTitle):
252        """
253        Public method to set the title of a bookmark.
254
255        @param node reference to the node to be changed (BookmarkNode)
256        @param newTitle title to be set (string)
257        """
258        if not self.__loaded:
259            return
260
261        command = ChangeBookmarkCommand(self, node, newTitle, True)
262        self.__commands.push(command)
263
264    def setUrl(self, node, newUrl):
265        """
266        Public method to set the URL of a bookmark.
267
268        @param node reference to the node to be changed (BookmarkNode)
269        @param newUrl URL to be set (string)
270        """
271        if not self.__loaded:
272            return
273
274        command = ChangeBookmarkCommand(self, node, newUrl, False)
275        self.__commands.push(command)
276
277    def setNodeChanged(self, node):
278        """
279        Public method to signal changes of bookmarks other than title, URL
280        or timestamp.
281
282        @param node reference to the bookmark (BookmarkNode)
283        """
284        self.__saveTimer.changeOccurred()
285
286    def setTimestamp(self, node, timestampType, timestamp):
287        """
288        Public method to set the URL of a bookmark.
289
290        @param node reference to the node to be changed (BookmarkNode)
291        @param timestampType type of the timestamp to set
292            (BookmarkNode.TsAdded, BookmarkNode.TsModified,
293            BookmarkNode.TsVisited)
294        @param timestamp timestamp to set (QDateTime)
295        """
296        if not self.__loaded:
297            return
298
299        if timestampType == BookmarkNode.TsAdded:
300            node.added = timestamp
301        elif timestampType == BookmarkNode.TsModified:
302            node.modified = timestamp
303        elif timestampType == BookmarkNode.TsVisited:
304            node.visited = timestamp
305        self.__saveTimer.changeOccurred()
306
307    def incVisitCount(self, node):
308        """
309        Public method to increment the visit count of a bookmark.
310
311        @param node reference to the node to be changed (BookmarkNode)
312        """
313        if not self.__loaded:
314            return
315
316        if node:
317            node.visitCount += 1
318            self.__saveTimer.changeOccurred()
319
320    def setVisitCount(self, node, count):
321        """
322        Public method to set the visit count of a bookmark.
323
324        @param node reference to the node to be changed (BookmarkNode)
325        @param count visit count to be set (int or str)
326        """
327        with contextlib.suppress(ValueError):
328            node.visitCount = int(count)
329            self.__saveTimer.changeOccurred()
330
331    def bookmarks(self):
332        """
333        Public method to get a reference to the root bookmark node.
334
335        @return reference to the root bookmark node (BookmarkNode)
336        """
337        if not self.__loaded:
338            self.load()
339
340        return self.__bookmarkRootNode
341
342    def menu(self):
343        """
344        Public method to get a reference to the bookmarks menu node.
345
346        @return reference to the bookmarks menu node (BookmarkNode)
347        """
348        if not self.__loaded:
349            self.load()
350
351        return self.__menu
352
353    def toolbar(self):
354        """
355        Public method to get a reference to the bookmarks toolbar node.
356
357        @return reference to the bookmarks toolbar node (BookmarkNode)
358        """
359        if not self.__loaded:
360            self.load()
361
362        return self.__toolbar
363
364    def bookmarksModel(self):
365        """
366        Public method to get a reference to the bookmarks model.
367
368        @return reference to the bookmarks model (BookmarksModel)
369        """
370        if self.__bookmarksModel is None:
371            from .BookmarksModel import BookmarksModel
372            self.__bookmarksModel = BookmarksModel(self, self)
373        return self.__bookmarksModel
374
375    def importBookmarks(self):
376        """
377        Public method to import bookmarks.
378        """
379        from .BookmarksImportDialog import BookmarksImportDialog
380        dlg = BookmarksImportDialog()
381        if dlg.exec() == QDialog.DialogCode.Accepted:
382            importRootNode = dlg.getImportedBookmarks()
383            if importRootNode is not None:
384                self.addBookmark(self.menu(), importRootNode)
385
386    def exportBookmarks(self):
387        """
388        Public method to export the bookmarks.
389        """
390        fileName, selectedFilter = E5FileDialog.getSaveFileNameAndFilter(
391            None,
392            self.tr("Export Bookmarks"),
393            "eric6_bookmarks.xbel",
394            self.tr("XBEL bookmarks (*.xbel);;"
395                    "XBEL bookmarks (*.xml);;"
396                    "HTML Bookmarks (*.html)"))
397        if not fileName:
398            return
399
400        ext = QFileInfo(fileName).suffix()
401        if not ext:
402            ex = selectedFilter.split("(*")[1].split(")")[0]
403            if ex:
404                fileName += ex
405
406        ext = QFileInfo(fileName).suffix()
407        if ext == "html":
408            from .NsHtmlWriter import NsHtmlWriter
409            writer = NsHtmlWriter()
410        else:
411            from .XbelWriter import XbelWriter
412            writer = XbelWriter()
413        if not writer.write(fileName, self.__bookmarkRootNode):
414            E5MessageBox.critical(
415                None,
416                self.tr("Exporting Bookmarks"),
417                self.tr("""Error exporting bookmarks to <b>{0}</b>.""")
418                .format(fileName))
419
420    def faviconChanged(self, url):
421        """
422        Public slot to update the icon image for an URL.
423
424        @param url URL of the icon to update (QUrl or string)
425        """
426        if isinstance(url, QUrl):
427            url = url.toString()
428        nodes = self.bookmarksForUrl(url)
429        for node in nodes:
430            self.bookmarksModel().entryChanged(node)
431
432    def bookmarkForUrl(self, url, start=StartRoot):
433        """
434        Public method to get a bookmark node for a given URL.
435
436        @param url URL of the bookmark to search for (QUrl or string)
437        @param start indicator for the start of the search
438            (StartRoot, StartMenu, StartToolBar)
439        @return bookmark node for the given url (BookmarkNode)
440        """
441        if start == StartMenu:
442            startNode = self.__menu
443        elif start == StartToolBar:
444            startNode = self.__toolbar
445        else:
446            startNode = self.__bookmarkRootNode
447        if startNode is None:
448            return None
449
450        if isinstance(url, QUrl):
451            url = url.toString()
452
453        return self.__searchBookmark(url, startNode)
454
455    def __searchBookmark(self, url, startNode):
456        """
457        Private method get a bookmark node for a given URL.
458
459        @param url URL of the bookmark to search for (string)
460        @param startNode reference to the node to start searching
461            (BookmarkNode)
462        @return bookmark node for the given url (BookmarkNode)
463        """
464        bm = None
465        for node in startNode.children():
466            if node.type() == BookmarkNode.Folder:
467                bm = self.__searchBookmark(url, node)
468            elif (
469                node.type() == BookmarkNode.Bookmark and
470                node.url == url
471            ):
472                bm = node
473            if bm is not None:
474                return bm
475        return None
476
477    def bookmarksForUrl(self, url, start=StartRoot):
478        """
479        Public method to get a list of bookmark nodes for a given URL.
480
481        @param url URL of the bookmarks to search for (QUrl or string)
482        @param start indicator for the start of the search
483            (StartRoot, StartMenu, StartToolBar)
484        @return list of bookmark nodes for the given url (list of BookmarkNode)
485        """
486        if start == StartMenu:
487            startNode = self.__menu
488        elif start == StartToolBar:
489            startNode = self.__toolbar
490        else:
491            startNode = self.__bookmarkRootNode
492        if startNode is None:
493            return []
494
495        if isinstance(url, QUrl):
496            url = url.toString()
497
498        return self.__searchBookmarks(url, startNode)
499
500    def __searchBookmarks(self, url, startNode):
501        """
502        Private method get a list of bookmark nodes for a given URL.
503
504        @param url URL of the bookmarks to search for (string)
505        @param startNode reference to the node to start searching
506            (BookmarkNode)
507        @return list of bookmark nodes for the given url (list of BookmarkNode)
508        """
509        bm = []
510        for node in startNode.children():
511            if node.type() == BookmarkNode.Folder:
512                bm.extend(self.__searchBookmarks(url, node))
513            elif (
514                node.type() == BookmarkNode.Bookmark and
515                node.url == url
516            ):
517                bm.append(node)
518        return bm
519
520
521class RemoveBookmarksCommand(QUndoCommand):
522    """
523    Class implementing the Remove undo command.
524    """
525    def __init__(self, bookmarksManager, parent, row):
526        """
527        Constructor
528
529        @param bookmarksManager reference to the bookmarks manager
530            (BookmarksManager)
531        @param parent reference to the parent node (BookmarkNode)
532        @param row row number of bookmark (integer)
533        """
534        super().__init__(
535            QCoreApplication.translate("BookmarksManager", "Remove Bookmark"))
536
537        self._row = row
538        self._bookmarksManager = bookmarksManager
539        try:
540            self._node = parent.children()[row]
541        except IndexError:
542            self._node = BookmarkNode()
543        self._parent = parent
544
545    def undo(self):
546        """
547        Public slot to perform the undo action.
548        """
549        self._parent.add(self._node, self._row)
550        self._bookmarksManager.entryAdded.emit(self._node)
551
552    def redo(self):
553        """
554        Public slot to perform the redo action.
555        """
556        self._parent.remove(self._node)
557        self._bookmarksManager.entryRemoved.emit(
558            self._parent, self._row, self._node)
559
560
561class InsertBookmarksCommand(RemoveBookmarksCommand):
562    """
563    Class implementing the Insert undo command.
564    """
565    def __init__(self, bookmarksManager, parent, node, row):
566        """
567        Constructor
568
569        @param bookmarksManager reference to the bookmarks manager
570            (BookmarksManager)
571        @param parent reference to the parent node (BookmarkNode)
572        @param node reference to the node to be inserted (BookmarkNode)
573        @param row row number of bookmark (integer)
574        """
575        RemoveBookmarksCommand.__init__(self, bookmarksManager, parent, row)
576        self.setText(QCoreApplication.translate(
577            "BookmarksManager", "Insert Bookmark"))
578        self._node = node
579
580    def undo(self):
581        """
582        Public slot to perform the undo action.
583        """
584        RemoveBookmarksCommand.redo(self)
585
586    def redo(self):
587        """
588        Public slot to perform the redo action.
589        """
590        RemoveBookmarksCommand.undo(self)
591
592
593class ChangeBookmarkCommand(QUndoCommand):
594    """
595    Class implementing the Insert undo command.
596    """
597    def __init__(self, bookmarksManager, node, newValue, title):
598        """
599        Constructor
600
601        @param bookmarksManager reference to the bookmarks manager
602            (BookmarksManager)
603        @param node reference to the node to be changed (BookmarkNode)
604        @param newValue new value to be set (string)
605        @param title flag indicating a change of the title (True) or
606            the URL (False) (boolean)
607        """
608        super().__init__()
609
610        self._bookmarksManager = bookmarksManager
611        self._title = title
612        self._newValue = newValue
613        self._node = node
614
615        if self._title:
616            self._oldValue = self._node.title
617            self.setText(QCoreApplication.translate(
618                "BookmarksManager", "Name Change"))
619        else:
620            self._oldValue = self._node.url
621            self.setText(QCoreApplication.translate(
622                "BookmarksManager", "Address Change"))
623
624    def undo(self):
625        """
626        Public slot to perform the undo action.
627        """
628        if self._title:
629            self._node.title = self._oldValue
630        else:
631            self._node.url = self._oldValue
632        self._bookmarksManager.entryChanged.emit(self._node)
633
634    def redo(self):
635        """
636        Public slot to perform the redo action.
637        """
638        if self._title:
639            self._node.title = self._newValue
640        else:
641            self._node.url = self._newValue
642        self._bookmarksManager.entryChanged.emit(self._node)
643