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