|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2009 - 2022 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing the history manager. |
|
8 """ |
|
9 |
|
10 import os |
|
11 import pathlib |
|
12 |
|
13 from PyQt6.QtCore import ( |
|
14 pyqtSignal, pyqtSlot, QDateTime, QDate, QTime, QUrl, QTimer, QFile, |
|
15 QIODevice, QByteArray, QDataStream, QTemporaryFile, QObject |
|
16 ) |
|
17 |
|
18 from EricWidgets import EricMessageBox |
|
19 |
|
20 from Utilities.AutoSaver import AutoSaver |
|
21 import Utilities |
|
22 import Preferences |
|
23 |
|
24 HISTORY_VERSION_42 = 42 |
|
25 HISTORY_VERSION_60 = 60 |
|
26 HISTORY_VERSIONS = [HISTORY_VERSION_60, HISTORY_VERSION_42] |
|
27 |
|
28 |
|
29 class HistoryEntry: |
|
30 """ |
|
31 Class implementing a history entry. |
|
32 """ |
|
33 def __init__(self, url=None, dateTime=None, title=None, visitCount=None): |
|
34 """ |
|
35 Constructor |
|
36 |
|
37 @param url URL of the history entry (string) |
|
38 @param dateTime date and time this entry was created (QDateTime) |
|
39 @param title title string for the history entry (string) |
|
40 @param visitCount number of visits of this URL (int) |
|
41 """ |
|
42 self.url = url and url or "" |
|
43 self.dateTime = dateTime and dateTime or QDateTime() |
|
44 self.title = title and title or "" |
|
45 self.visitCount = visitCount and visitCount or 0 |
|
46 |
|
47 def __eq__(self, other): |
|
48 """ |
|
49 Special method determining equality. |
|
50 |
|
51 @param other reference to the history entry to compare against |
|
52 (HistoryEntry) |
|
53 @return flag indicating equality (boolean) |
|
54 """ |
|
55 return ( |
|
56 other.title == self.title and |
|
57 other.url == self.url and |
|
58 other.dateTime == self.dateTime |
|
59 ) |
|
60 |
|
61 def __lt__(self, other): |
|
62 """ |
|
63 Special method determining less relation. |
|
64 |
|
65 Note: History is sorted in reverse order by date and time |
|
66 |
|
67 @param other reference to the history entry to compare against |
|
68 (HistoryEntry) |
|
69 @return flag indicating less (boolean) |
|
70 """ |
|
71 return self.dateTime > other.dateTime |
|
72 |
|
73 def userTitle(self): |
|
74 """ |
|
75 Public method to get the title of the history entry. |
|
76 |
|
77 @return title of the entry (string) |
|
78 """ |
|
79 if not self.title: |
|
80 page = pathlib.Path(QUrl(self.url).path()).name |
|
81 if page: |
|
82 return page |
|
83 return self.url |
|
84 return self.title |
|
85 |
|
86 def isValid(self): |
|
87 """ |
|
88 Public method to determine validity. |
|
89 |
|
90 @return flag indicating validity |
|
91 @rtype bool |
|
92 """ |
|
93 return bool(self.url) and self.dateTime.isValid() |
|
94 |
|
95 |
|
96 class HistoryManager(QObject): |
|
97 """ |
|
98 Class implementing the history manager. |
|
99 |
|
100 @signal historyCleared() emitted after the history has been cleared |
|
101 @signal historyReset() emitted after the history has been reset |
|
102 @signal entryAdded(HistoryEntry) emitted after a history entry has been |
|
103 added |
|
104 @signal entryRemoved(HistoryEntry) emitted after a history entry has been |
|
105 removed |
|
106 @signal entryUpdated(int) emitted after a history entry has been updated |
|
107 @signal historySaved() emitted after the history was saved |
|
108 """ |
|
109 historyCleared = pyqtSignal() |
|
110 historyReset = pyqtSignal() |
|
111 entryAdded = pyqtSignal(HistoryEntry) |
|
112 entryRemoved = pyqtSignal(HistoryEntry) |
|
113 entryUpdated = pyqtSignal(int) |
|
114 historySaved = pyqtSignal() |
|
115 |
|
116 def __init__(self, parent=None): |
|
117 """ |
|
118 Constructor |
|
119 |
|
120 @param parent reference to the parent object (QObject) |
|
121 """ |
|
122 super().__init__(parent) |
|
123 |
|
124 self.__saveTimer = AutoSaver(self, self.save) |
|
125 self.__daysToExpire = Preferences.getWebBrowser("HistoryLimit") |
|
126 self.__history = [] |
|
127 self.__lastSavedUrl = "" |
|
128 |
|
129 self.__expiredTimer = QTimer(self) |
|
130 self.__expiredTimer.setSingleShot(True) |
|
131 self.__expiredTimer.timeout.connect(self.__checkForExpired) |
|
132 |
|
133 self.__frequencyTimer = QTimer(self) |
|
134 self.__frequencyTimer.setSingleShot(True) |
|
135 self.__frequencyTimer.timeout.connect(self.__refreshFrequencies) |
|
136 |
|
137 self.entryAdded.connect(self.__saveTimer.changeOccurred) |
|
138 self.entryRemoved.connect(self.__saveTimer.changeOccurred) |
|
139 |
|
140 self.__load() |
|
141 |
|
142 from .HistoryModel import HistoryModel |
|
143 from .HistoryFilterModel import HistoryFilterModel |
|
144 from .HistoryTreeModel import HistoryTreeModel |
|
145 |
|
146 self.__historyModel = HistoryModel(self, self) |
|
147 self.__historyFilterModel = HistoryFilterModel( |
|
148 self.__historyModel, self) |
|
149 self.__historyTreeModel = HistoryTreeModel( |
|
150 self.__historyFilterModel, self) |
|
151 |
|
152 self.__startFrequencyTimer() |
|
153 |
|
154 def close(self): |
|
155 """ |
|
156 Public method to close the history manager. |
|
157 """ |
|
158 # remove history items on application exit |
|
159 if self.__daysToExpire == -2: |
|
160 self.clear() |
|
161 self.__saveTimer.saveIfNeccessary() |
|
162 |
|
163 def history(self): |
|
164 """ |
|
165 Public method to return the history. |
|
166 |
|
167 @return reference to the list of history entries (list of HistoryEntry) |
|
168 """ |
|
169 return self.__history[:] |
|
170 |
|
171 def setHistory(self, history, loadedAndSorted=False): |
|
172 """ |
|
173 Public method to set a new history. |
|
174 |
|
175 @param history reference to the list of history entries to be set |
|
176 (list of HistoryEntry) |
|
177 @param loadedAndSorted flag indicating that the list is sorted |
|
178 (boolean) |
|
179 """ |
|
180 self.__history = history[:] |
|
181 if not loadedAndSorted: |
|
182 self.__history.sort() |
|
183 |
|
184 self.__checkForExpired() |
|
185 |
|
186 if loadedAndSorted: |
|
187 try: |
|
188 self.__lastSavedUrl = self.__history[0].url |
|
189 except IndexError: |
|
190 self.__lastSavedUrl = "" |
|
191 else: |
|
192 self.__lastSavedUrl = "" |
|
193 self.__saveTimer.changeOccurred() |
|
194 self.historyReset.emit() |
|
195 |
|
196 def __findFirstHistoryEntry(self, url): |
|
197 """ |
|
198 Private method to find the first entry for the given URL. |
|
199 |
|
200 @param url URL to search for |
|
201 @type str |
|
202 @return first entry for the given URL |
|
203 @rtype HistoryEntry |
|
204 """ |
|
205 for index in range(len(self.__history)): |
|
206 if url == self.__history[index].url: |
|
207 return self.__history[index] |
|
208 |
|
209 # not found, return an empty entry |
|
210 return HistoryEntry() |
|
211 |
|
212 def __updateVisitCount(self, url, count): |
|
213 """ |
|
214 Private method to update the visit count for all entries of the |
|
215 given URL. |
|
216 |
|
217 @param url URL to be updated |
|
218 @type str |
|
219 @param count new visit count |
|
220 @type int |
|
221 """ |
|
222 for index in range(len(self.__history)): |
|
223 if url == self.__history[index].url: |
|
224 self.__history[index].visitCount = count |
|
225 |
|
226 def addHistoryEntry(self, view): |
|
227 """ |
|
228 Public method to add a history entry. |
|
229 |
|
230 @param view reference to the view to add an entry for |
|
231 @type WebBrowserView |
|
232 """ |
|
233 import WebBrowser.WebBrowserWindow |
|
234 if WebBrowser.WebBrowserWindow.WebBrowserWindow.isPrivate(): |
|
235 return |
|
236 |
|
237 url = view.url() |
|
238 title = view.title() |
|
239 |
|
240 if url.scheme() not in ["eric", "about", "data", "chrome"]: |
|
241 cleanUrlStr = self.__cleanUrlStr(url) |
|
242 firstEntry = self.__findFirstHistoryEntry(cleanUrlStr) |
|
243 if firstEntry.isValid(): |
|
244 visitCount = firstEntry.visitCount + 1 |
|
245 self.__updateVisitCount(cleanUrlStr, visitCount) |
|
246 else: |
|
247 visitCount = 1 |
|
248 itm = HistoryEntry(cleanUrlStr, |
|
249 QDateTime.currentDateTime(), |
|
250 title, |
|
251 visitCount) |
|
252 self.__history.insert(0, itm) |
|
253 self.entryAdded.emit(itm) |
|
254 if len(self.__history) == 1: |
|
255 self.__checkForExpired() |
|
256 |
|
257 def updateHistoryEntry(self, url, title): |
|
258 """ |
|
259 Public method to update a history entry. |
|
260 |
|
261 @param url URL of the entry to update (string) |
|
262 @param title title of the entry to update (string) |
|
263 """ |
|
264 if QUrl(url).scheme() not in ["eric", "about", "data", "chrome"]: |
|
265 cleanUrlStr = self.__cleanUrlStr(QUrl(url)) |
|
266 for index in range(len(self.__history)): |
|
267 if cleanUrlStr == self.__history[index].url: |
|
268 self.__history[index].title = title |
|
269 self.__saveTimer.changeOccurred() |
|
270 if not self.__lastSavedUrl: |
|
271 self.__lastSavedUrl = self.__history[index].url |
|
272 self.entryUpdated.emit(index) |
|
273 break |
|
274 |
|
275 def removeHistoryEntry(self, url, title=""): |
|
276 """ |
|
277 Public method to remove a history entry. |
|
278 |
|
279 @param url URL of the entry to remove (QUrl) |
|
280 @param title title of the entry to remove (string) |
|
281 """ |
|
282 if url.scheme() not in ["eric", "about", "data", "chrome"]: |
|
283 cleanUrlStr = self.__cleanUrlStr(url) |
|
284 for index in range(len(self.__history)): |
|
285 if ( |
|
286 cleanUrlStr == self.__history[index].url and |
|
287 (not title or title == self.__history[index].title) |
|
288 ): |
|
289 itm = self.__history[index] |
|
290 self.__lastSavedUrl = "" |
|
291 self.__history.remove(itm) |
|
292 self.entryRemoved.emit(itm) |
|
293 break |
|
294 |
|
295 def __cleanUrl(self, url): |
|
296 """ |
|
297 Private method to generate a clean URL usable for the history entry. |
|
298 |
|
299 @param url original URL |
|
300 @type QUrl |
|
301 @return cleaned URL |
|
302 @rtype QUrl |
|
303 """ |
|
304 cleanurl = QUrl(url) |
|
305 if cleanurl.password(): |
|
306 # don't save the password in the history |
|
307 cleanurl.setPassword("") |
|
308 if cleanurl.host(): |
|
309 # convert host to lower case |
|
310 cleanurl.setHost(url.host().lower()) |
|
311 |
|
312 return cleanurl |
|
313 |
|
314 def __cleanUrlStr(self, url): |
|
315 """ |
|
316 Private method to generate a clean URL usable for the history entry. |
|
317 |
|
318 @param url original URL |
|
319 @type QUrl |
|
320 @return cleaned URL |
|
321 @rtype str |
|
322 """ |
|
323 cleanurl = self.__cleanUrl(url) |
|
324 return cleanurl.toString() |
|
325 |
|
326 def historyModel(self): |
|
327 """ |
|
328 Public method to get a reference to the history model. |
|
329 |
|
330 @return reference to the history model (HistoryModel) |
|
331 """ |
|
332 return self.__historyModel |
|
333 |
|
334 def historyFilterModel(self): |
|
335 """ |
|
336 Public method to get a reference to the history filter model. |
|
337 |
|
338 @return reference to the history filter model (HistoryFilterModel) |
|
339 """ |
|
340 return self.__historyFilterModel |
|
341 |
|
342 def historyTreeModel(self): |
|
343 """ |
|
344 Public method to get a reference to the history tree model. |
|
345 |
|
346 @return reference to the history tree model (HistoryTreeModel) |
|
347 """ |
|
348 return self.__historyTreeModel |
|
349 |
|
350 def __checkForExpired(self): |
|
351 """ |
|
352 Private slot to check entries for expiration. |
|
353 """ |
|
354 if self.__daysToExpire < 0 or len(self.__history) == 0: |
|
355 return |
|
356 |
|
357 now = QDateTime.currentDateTime() |
|
358 nextTimeout = 0 |
|
359 |
|
360 while self.__history: |
|
361 checkForExpired = QDateTime(self.__history[-1].dateTime) |
|
362 checkForExpired.setDate( |
|
363 checkForExpired.date().addDays(self.__daysToExpire)) |
|
364 nextTimeout = ( |
|
365 7 * 86400 |
|
366 if now.daysTo(checkForExpired) > 7 else |
|
367 now.secsTo(checkForExpired) |
|
368 ) |
|
369 if nextTimeout > 0: |
|
370 break |
|
371 |
|
372 itm = self.__history.pop(-1) |
|
373 self.__lastSavedUrl = "" |
|
374 self.entryRemoved.emit(itm) |
|
375 self.__saveTimer.saveIfNeccessary() |
|
376 |
|
377 if nextTimeout > 0: |
|
378 self.__expiredTimer.start(nextTimeout * 1000) |
|
379 |
|
380 def daysToExpire(self): |
|
381 """ |
|
382 Public method to get the days for entry expiration. |
|
383 |
|
384 @return days for entry expiration (integer) |
|
385 """ |
|
386 return self.__daysToExpire |
|
387 |
|
388 def setDaysToExpire(self, limit): |
|
389 """ |
|
390 Public method to set the days for entry expiration. |
|
391 |
|
392 @param limit days for entry expiration (integer) |
|
393 """ |
|
394 if self.__daysToExpire == limit: |
|
395 return |
|
396 |
|
397 self.__daysToExpire = limit |
|
398 self.__checkForExpired() |
|
399 self.__saveTimer.changeOccurred() |
|
400 |
|
401 def preferencesChanged(self): |
|
402 """ |
|
403 Public method to indicate a change of preferences. |
|
404 """ |
|
405 self.setDaysToExpire(Preferences.getWebBrowser("HistoryLimit")) |
|
406 |
|
407 @pyqtSlot() |
|
408 def clear(self, period=0): |
|
409 """ |
|
410 Public slot to clear the complete history. |
|
411 |
|
412 @param period history period in milliseconds to be cleared (integer) |
|
413 """ |
|
414 if period == 0: |
|
415 self.__history = [] |
|
416 self.historyReset.emit() |
|
417 else: |
|
418 breakMS = QDateTime.currentMSecsSinceEpoch() - period |
|
419 while ( |
|
420 self.__history and |
|
421 (QDateTime(self.__history[0].dateTime).toMSecsSinceEpoch() > |
|
422 breakMS) |
|
423 ): |
|
424 itm = self.__history.pop(0) |
|
425 self.entryRemoved.emit(itm) |
|
426 self.__lastSavedUrl = "" |
|
427 self.__saveTimer.changeOccurred() |
|
428 self.__saveTimer.saveIfNeccessary() |
|
429 self.historyCleared.emit() |
|
430 |
|
431 def getFileName(self): |
|
432 """ |
|
433 Public method to get the file name of the history file. |
|
434 |
|
435 @return name of the history file (string) |
|
436 """ |
|
437 return os.path.join(Utilities.getConfigDir(), "web_browser", "history") |
|
438 |
|
439 def reload(self): |
|
440 """ |
|
441 Public method to reload the history. |
|
442 """ |
|
443 self.__load() |
|
444 |
|
445 def __load(self): |
|
446 """ |
|
447 Private method to load the saved history entries from disk. |
|
448 """ |
|
449 historyFile = QFile(self.getFileName()) |
|
450 if not historyFile.exists(): |
|
451 return |
|
452 if not historyFile.open(QIODevice.OpenModeFlag.ReadOnly): |
|
453 EricMessageBox.warning( |
|
454 None, |
|
455 self.tr("Loading History"), |
|
456 self.tr( |
|
457 """<p>Unable to open history file <b>{0}</b>.<br/>""" |
|
458 """Reason: {1}</p>""") |
|
459 .format(historyFile.fileName, historyFile.errorString())) |
|
460 return |
|
461 |
|
462 history = [] |
|
463 |
|
464 # double check, that the history file is sorted as it is read |
|
465 needToSort = False |
|
466 lastInsertedItem = HistoryEntry() |
|
467 data = QByteArray(historyFile.readAll()) |
|
468 stream = QDataStream(data, QIODevice.OpenModeFlag.ReadOnly) |
|
469 stream.setVersion(QDataStream.Version.Qt_4_6) |
|
470 while not stream.atEnd(): |
|
471 ver = stream.readUInt32() |
|
472 if ver not in HISTORY_VERSIONS: |
|
473 continue |
|
474 itm = HistoryEntry() |
|
475 itm.url = Utilities.readStringFromStream(stream) |
|
476 stream >> itm.dateTime |
|
477 itm.title = Utilities.readStringFromStream(stream) |
|
478 if ver == HISTORY_VERSION_60: |
|
479 itm.visitCount = stream.readUInt32() |
|
480 |
|
481 if not itm.dateTime.isValid(): |
|
482 continue |
|
483 |
|
484 if itm == lastInsertedItem: |
|
485 if not lastInsertedItem.title and len(history) > 0: |
|
486 history[0].title = itm.title |
|
487 continue |
|
488 |
|
489 if ver == HISTORY_VERSION_42: |
|
490 firstEntry = self.__findFirstHistoryEntry(itm.url) |
|
491 if firstEntry.isValid(): |
|
492 visitCount = firstEntry.visitCount + 1 |
|
493 self.__updateVisitCount(itm.url, visitCount) |
|
494 else: |
|
495 visitCount = 1 |
|
496 itm.visitCount = visitCount |
|
497 |
|
498 if not needToSort and history and lastInsertedItem < itm: |
|
499 needToSort = True |
|
500 |
|
501 history.insert(0, itm) |
|
502 lastInsertedItem = itm |
|
503 historyFile.close() |
|
504 |
|
505 if needToSort: |
|
506 history.sort() |
|
507 |
|
508 self.setHistory(history, True) |
|
509 |
|
510 # if the history had to be sorted, rewrite the history sorted |
|
511 if needToSort: |
|
512 self.__lastSavedUrl = "" |
|
513 self.__saveTimer.changeOccurred() |
|
514 |
|
515 def save(self): |
|
516 """ |
|
517 Public slot to save the history entries to disk. |
|
518 """ |
|
519 historyFile = QFile(self.getFileName()) |
|
520 if not historyFile.exists(): |
|
521 self.__lastSavedUrl = "" |
|
522 |
|
523 saveAll = self.__lastSavedUrl == "" |
|
524 first = len(self.__history) - 1 |
|
525 if not saveAll: |
|
526 # find the first one to save |
|
527 for index in range(len(self.__history)): |
|
528 if self.__history[index].url == self.__lastSavedUrl: |
|
529 first = index - 1 |
|
530 break |
|
531 if first == len(self.__history) - 1: |
|
532 saveAll = True |
|
533 |
|
534 if saveAll: |
|
535 # use a temporary file when saving everything |
|
536 f = QTemporaryFile() |
|
537 f.setAutoRemove(False) |
|
538 opened = f.open() |
|
539 else: |
|
540 f = historyFile |
|
541 opened = f.open(QIODevice.OpenModeFlag.Append) |
|
542 |
|
543 if not opened: |
|
544 EricMessageBox.warning( |
|
545 None, |
|
546 self.tr("Saving History"), |
|
547 self.tr( |
|
548 """<p>Unable to open history file <b>{0}</b>.<br/>""" |
|
549 """Reason: {1}</p>""") |
|
550 .format(f.fileName(), f.errorString())) |
|
551 return |
|
552 |
|
553 for index in range(first, -1, -1): |
|
554 data = QByteArray() |
|
555 stream = QDataStream(data, QIODevice.OpenModeFlag.WriteOnly) |
|
556 stream.setVersion(QDataStream.Version.Qt_4_6) |
|
557 itm = self.__history[index] |
|
558 stream.writeUInt32(HISTORY_VERSION_60) |
|
559 stream.writeString(itm.url.encode("utf-8")) |
|
560 stream << itm.dateTime |
|
561 stream.writeString(itm.title.encode('utf-8')) |
|
562 stream.writeUInt32(itm.visitCount) |
|
563 f.write(data) |
|
564 |
|
565 f.close() |
|
566 if saveAll: |
|
567 if historyFile.exists() and not historyFile.remove(): |
|
568 EricMessageBox.warning( |
|
569 None, |
|
570 self.tr("Saving History"), |
|
571 self.tr( |
|
572 """<p>Error removing old history file <b>{0}</b>.""" |
|
573 """<br/>Reason: {1}</p>""") |
|
574 .format(historyFile.fileName(), |
|
575 historyFile.errorString())) |
|
576 if not f.copy(historyFile.fileName()): |
|
577 EricMessageBox.warning( |
|
578 None, |
|
579 self.tr("Saving History"), |
|
580 self.tr( |
|
581 """<p>Error moving new history file over old one """ |
|
582 """(<b>{0}</b>).<br/>Reason: {1}</p>""") |
|
583 .format(historyFile.fileName(), f.errorString())) |
|
584 f.remove() # get rid of the temporary file |
|
585 self.historySaved.emit() |
|
586 try: |
|
587 self.__lastSavedUrl = self.__history[0].url |
|
588 except IndexError: |
|
589 self.__lastSavedUrl = "" |
|
590 |
|
591 def __refreshFrequencies(self): |
|
592 """ |
|
593 Private slot to recalculate the refresh frequencies. |
|
594 """ |
|
595 self.__historyFilterModel.recalculateFrequencies() |
|
596 self.__startFrequencyTimer() |
|
597 |
|
598 def __startFrequencyTimer(self): |
|
599 """ |
|
600 Private method to start the timer to recalculate the frequencies. |
|
601 """ |
|
602 tomorrow = QDateTime(QDate.currentDate().addDays(1), QTime(3, 0)) |
|
603 self.__frequencyTimer.start( |
|
604 QDateTime.currentDateTime().secsTo(tomorrow) * 1000) |
|
605 |
|
606 def siteVisitsCount(self, scheme, host): |
|
607 """ |
|
608 Public method to get the visit count for a web site using the given |
|
609 scheme. |
|
610 |
|
611 @param scheme scheme to look for |
|
612 @type str |
|
613 @param host host to look for |
|
614 @type str |
|
615 @return number of visits to this site |
|
616 @rtype int |
|
617 """ |
|
618 count = 0 |
|
619 url = "{0}://{1}".format(scheme.lower(), host.lower()) |
|
620 |
|
621 seenUrls = [] |
|
622 |
|
623 for index in range(len(self.__history)): |
|
624 historyUrl = self.__history[index].url |
|
625 if historyUrl.startswith(url) and historyUrl not in seenUrls: |
|
626 count += self.__history[index].visitCount |
|
627 seenUrls.append(historyUrl) |
|
628 |
|
629 return count |