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