HistoryController.sys.mjs (14210B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const lazy = {}; 6 7 import { getLogger } from "chrome://browser/content/firefoxview/helpers.mjs"; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 PlacesQuery: "resource://gre/modules/PlacesQuery.sys.mjs", 11 PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", 12 }); 13 14 let XPCOMUtils = ChromeUtils.importESModule( 15 "resource://gre/modules/XPCOMUtils.sys.mjs" 16 ).XPCOMUtils; 17 18 XPCOMUtils.defineLazyPreferenceGetter( 19 lazy, 20 "maxRowsPref", 21 "browser.firefox-view.max-history-rows", 22 -1 23 ); 24 25 const HISTORY_MAP_L10N_IDS = { 26 sidebar: { 27 "history-date-today": "sidebar-history-date-today", 28 "history-date-yesterday": "sidebar-history-date-yesterday", 29 "history-date-this-month": "sidebar-history-date-this-month", 30 "history-date-prev-month": "sidebar-history-date-prev-month", 31 }, 32 firefoxview: { 33 "history-date-today": "firefoxview-history-date-today", 34 "history-date-yesterday": "firefoxview-history-date-yesterday", 35 "history-date-this-month": "firefoxview-history-date-this-month", 36 "history-date-prev-month": "firefoxview-history-date-prev-month", 37 }, 38 }; 39 40 /** 41 * When sorting by date or site, each card "item" is a single visit. 42 * 43 * When sorting by date *and* site, each card "item" is a mapping of site 44 * domains to their respective list of visits. 45 * 46 * @typedef {HistoryVisit | [string, HistoryVisit[]]} CardItem 47 */ 48 49 /** 50 * A list of visits displayed on a card. 51 * 52 * @typedef {object} CardEntry 53 * 54 * @property {string} domain 55 * @property {CardItem[]} items 56 * @property {string} l10nId 57 */ 58 59 export class HistoryController { 60 /** 61 * @type {{ entries: CardEntry[]; searchQuery: string; sortOption: string; }} 62 */ 63 historyCache; 64 host; 65 searchQuery; 66 sortOption; 67 #todaysDate; 68 #yesterdaysDate; 69 70 constructor(host, options) { 71 this.placesQuery = new lazy.PlacesQuery(); 72 this.searchQuery = ""; 73 this.sortOption = "date"; 74 this.searchResultsLimit = options?.searchResultsLimit || 300; 75 this.component = HISTORY_MAP_L10N_IDS?.[options?.component] 76 ? options?.component 77 : "firefoxview"; 78 this.historyCache = { 79 entries: null, 80 searchQuery: null, 81 sortOption: null, 82 }; 83 this.host = host; 84 85 host.addController(this); 86 } 87 88 hostConnected() { 89 this.placesQuery.observeHistory(historyMap => this.updateCache(historyMap)); 90 } 91 92 hostDisconnected() { 93 this.placesQuery.close(); 94 } 95 96 deleteFromHistory() { 97 return lazy.PlacesUtils.history.remove(this.host.triggerNode.url); 98 } 99 100 onSearchQuery(e) { 101 this.searchQuery = e.detail.query; 102 this.updateCache(); 103 } 104 105 onChangeSortOption(e, value = e.target.value) { 106 this.sortOption = value; 107 this.updateCache(); 108 } 109 110 get historyVisits() { 111 return this.historyCache.entries || []; 112 } 113 114 get isHistoryPending() { 115 return this.historyCache.entries === null; 116 } 117 118 get searchResults() { 119 if (this.historyCache.searchQuery && this.historyCache.entries?.length) { 120 return this.historyCache.entries[0].items; 121 } 122 return null; 123 } 124 125 get totalVisitsCount() { 126 return this.historyVisits.reduce( 127 (count, entry) => count + entry.items.length, 128 0 129 ); 130 } 131 132 get isHistoryEmpty() { 133 return !this.historyVisits.length; 134 } 135 136 /** 137 * Update cached history. 138 * 139 * @param {CachedHistory} [historyMap] 140 * If provided, performs an update using the given data (instead of fetching 141 * it from the db). 142 */ 143 async updateCache(historyMap) { 144 const { searchQuery, sortOption } = this; 145 const entries = searchQuery 146 ? await this.#getVisitsForSearchQuery(searchQuery) 147 : await this.#getVisitsForSortOption(sortOption, historyMap); 148 if ( 149 this.searchQuery !== searchQuery || 150 this.sortOption !== sortOption || 151 !entries 152 ) { 153 // This query is stale, discard results and do not update the cache / UI. 154 return; 155 } 156 for (const { items } of entries) { 157 for (const item of items) { 158 switch (sortOption) { 159 case "datesite": { 160 // item is a [ domain, visit[] ] entry. 161 const [, visits] = item; 162 for (const visit of visits) { 163 this.#normalizeVisit(visit); 164 } 165 break; 166 } 167 default: 168 // item is a single visit. 169 this.#normalizeVisit(item); 170 } 171 } 172 } 173 this.historyCache = { entries, searchQuery, sortOption }; 174 this.host.requestUpdate(); 175 } 176 177 /** 178 * Normalize data for fxview-tabs-list. 179 * 180 * @param {HistoryVisit} visit 181 * The visit to format. 182 */ 183 #normalizeVisit(visit) { 184 visit.time = visit.date.getTime(); 185 visit.title = visit.title || visit.url; 186 visit.icon = `page-icon:${visit.url}`; 187 visit.primaryL10nId = "fxviewtabrow-tabs-list-tab"; 188 visit.primaryL10nArgs = JSON.stringify({ 189 targetURI: visit.url, 190 }); 191 visit.secondaryL10nId = "fxviewtabrow-options-menu-button"; 192 visit.secondaryL10nArgs = JSON.stringify({ 193 tabTitle: visit.title || visit.url, 194 }); 195 } 196 197 async #getVisitsForSearchQuery(searchQuery) { 198 let items = []; 199 try { 200 items = await this.placesQuery.searchHistory( 201 searchQuery, 202 this.searchResultsLimit 203 ); 204 } catch (e) { 205 getLogger("HistoryController").warn( 206 "There is a new search query in progress, so cancelling this one.", 207 e 208 ); 209 } 210 return [{ items }]; 211 } 212 213 async #getVisitsForSortOption(sortOption, historyMap) { 214 if (!historyMap) { 215 const fetchedHistory = await this.#fetchHistory(); 216 if (!fetchedHistory) { 217 return null; 218 } 219 historyMap = fetchedHistory; 220 } 221 switch (sortOption) { 222 case "date": 223 this.#setTodaysDate(); 224 return this.#getVisitsForDate(historyMap); 225 case "site": 226 return this.#getVisitsForSite(historyMap); 227 case "datesite": 228 this.#setTodaysDate(); 229 return this.#getVisitsForDateSite(historyMap); 230 case "lastvisited": 231 return this.#getVisitsForLastVisited(historyMap); 232 default: 233 return []; 234 } 235 } 236 237 #setTodaysDate() { 238 const now = new Date(); 239 this.#todaysDate = new Date( 240 now.getFullYear(), 241 now.getMonth(), 242 now.getDate() 243 ); 244 this.#yesterdaysDate = new Date( 245 now.getFullYear(), 246 now.getMonth(), 247 now.getDate() - 1 248 ); 249 } 250 251 /** 252 * Get a list of visits, sorted by date, in reverse chronological order. 253 * 254 * @param {Map<number, HistoryVisit[]>} historyMap 255 * @returns {CardEntry[]} 256 */ 257 #getVisitsForDate(historyMap) { 258 const entries = []; 259 const visitsFromToday = this.#getVisitsFromToday(historyMap); 260 const visitsFromYesterday = this.#getVisitsFromYesterday(historyMap); 261 const visitsByDay = this.#getVisitsByDay(historyMap); 262 const visitsByMonth = this.#getVisitsByMonth(historyMap); 263 264 // Add visits from today and yesterday. 265 if (visitsFromToday.length) { 266 entries.push({ 267 l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-today"], 268 items: visitsFromToday, 269 }); 270 } 271 if (visitsFromYesterday.length) { 272 entries.push({ 273 l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-yesterday"], 274 items: visitsFromYesterday, 275 }); 276 } 277 278 // Add visits from this month, grouped by day. 279 visitsByDay.forEach(visits => { 280 entries.push({ 281 l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-this-month"], 282 items: visits, 283 }); 284 }); 285 286 // Add visits from previous months, grouped by month. 287 visitsByMonth.forEach(visits => { 288 entries.push({ 289 l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-prev-month"], 290 items: visits, 291 }); 292 }); 293 return entries; 294 } 295 296 #getVisitsFromToday(cachedHistory) { 297 const mapKey = this.placesQuery.getStartOfDayTimestamp(this.#todaysDate); 298 const visits = cachedHistory.get(mapKey) ?? []; 299 return [...visits]; 300 } 301 302 #getVisitsFromYesterday(cachedHistory) { 303 const mapKey = this.placesQuery.getStartOfDayTimestamp( 304 this.#yesterdaysDate 305 ); 306 const visits = cachedHistory.get(mapKey) ?? []; 307 return [...visits]; 308 } 309 310 /** 311 * Get a list of visits per day for each day on this month, excluding today 312 * and yesterday. 313 * 314 * @param {CachedHistory} cachedHistory 315 * The history cache to process. 316 * @returns {CardItem[]} 317 * A list of visits for each day. 318 */ 319 #getVisitsByDay(cachedHistory) { 320 const visitsPerDay = []; 321 for (const [time, visits] of cachedHistory.entries()) { 322 const date = new Date(time); 323 if ( 324 this.#isSameDate(date, this.#todaysDate) || 325 this.#isSameDate(date, this.#yesterdaysDate) 326 ) { 327 continue; 328 } else if (!this.#isSameMonth(date, this.#todaysDate)) { 329 break; 330 } else { 331 visitsPerDay.push(visits); 332 } 333 } 334 return visitsPerDay; 335 } 336 337 /** 338 * Get a list of visits per month for each month, excluding this one, and 339 * excluding yesterday's visits if yesterday happens to fall on the previous 340 * month. 341 * 342 * @param {CachedHistory} cachedHistory 343 * The history cache to process. 344 * @returns {CardItem[]} 345 * A list of visits for each month. 346 */ 347 #getVisitsByMonth(cachedHistory) { 348 const visitsPerMonth = []; 349 let previousMonth = null; 350 for (const [time, visits] of cachedHistory.entries()) { 351 const date = new Date(time); 352 if ( 353 this.#isSameMonth(date, this.#todaysDate) || 354 this.#isSameDate(date, this.#yesterdaysDate) 355 ) { 356 continue; 357 } 358 const month = this.placesQuery.getStartOfMonthTimestamp(date); 359 if (month !== previousMonth) { 360 visitsPerMonth.push(visits); 361 } else if (this.sortOption === "datesite") { 362 // CardItem type is currently Map<string, HistoryVisit[]>. 363 visitsPerMonth[visitsPerMonth.length - 1] = this.#mergeMaps( 364 visitsPerMonth.at(-1), 365 visits 366 ); 367 } else { 368 visitsPerMonth[visitsPerMonth.length - 1] = visitsPerMonth 369 .at(-1) 370 .concat(visits); 371 } 372 previousMonth = month; 373 } 374 return visitsPerMonth; 375 } 376 377 /** 378 * Given two date instances, check if their dates are equivalent. 379 * 380 * @param {Date} dateToCheck 381 * @param {Date} date 382 * @returns {boolean} 383 * Whether both date instances have equivalent dates. 384 */ 385 #isSameDate(dateToCheck, date) { 386 return ( 387 dateToCheck.getDate() === date.getDate() && 388 this.#isSameMonth(dateToCheck, date) 389 ); 390 } 391 392 /** 393 * Given two date instances, check if their months are equivalent. 394 * 395 * @param {Date} dateToCheck 396 * @param {Date} month 397 * @returns {boolean} 398 * Whether both date instances have equivalent months. 399 */ 400 #isSameMonth(dateToCheck, month) { 401 return ( 402 dateToCheck.getMonth() === month.getMonth() && 403 dateToCheck.getFullYear() === month.getFullYear() 404 ); 405 } 406 407 /** 408 * Merge two maps of (domain: string) => HistoryVisit[] into a single map. 409 * 410 * @param {Map<string, HistoryVisit[]>} oldMap 411 * @param {Map<string, HistoryVisit[]>} newMap 412 * @returns {Map<string, HistoryVisit[]>} 413 */ 414 #mergeMaps(oldMap, newMap) { 415 const map = new Map(oldMap); 416 for (const [domain, newVisits] of newMap) { 417 const oldVisits = map.get(domain); 418 map.set(domain, oldVisits?.concat(newVisits) ?? newVisits); 419 } 420 return map; 421 } 422 423 /** 424 * Get a list of visits, sorted by site, in alphabetical order. 425 * 426 * @param {Map<string, HistoryVisit[]>} historyMap 427 * @returns {CardEntry[]} 428 */ 429 #getVisitsForSite(historyMap) { 430 return Array.from(historyMap.entries(), ([domain, items]) => ({ 431 domain, 432 items, 433 l10nId: domain ? null : "firefoxview-history-site-localhost", 434 })).sort((a, b) => a.domain.localeCompare(b.domain)); 435 } 436 437 /** 438 * Get a list of visits, sorted by date and site, in reverse chronological 439 * order. 440 * 441 * @param {Map<number, Map<string, HistoryVisit[]>>} historyMap 442 * @returns {CardEntry[]} 443 */ 444 #getVisitsForDateSite(historyMap) { 445 const entries = []; 446 const visitsFromToday = this.#getVisitsFromToday(historyMap); 447 const visitsFromYesterday = this.#getVisitsFromYesterday(historyMap); 448 const visitsByDay = this.#getVisitsByDay(historyMap); 449 const visitsByMonth = this.#getVisitsByMonth(historyMap); 450 451 /** 452 * Sorts items alphabetically by domain name. 453 * 454 * @param {[string, HistoryVisit[]][]} items 455 * @returns {[string, HistoryVisit[]][]} The items in sorted order. 456 */ 457 function sortItems(items) { 458 return items.sort(([aDomain], [bDomain]) => 459 aDomain.localeCompare(bDomain) 460 ); 461 } 462 463 // Add visits from today and yesterday. 464 if (visitsFromToday.length) { 465 entries.push({ 466 l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-today"], 467 items: sortItems(visitsFromToday), 468 }); 469 } 470 if (visitsFromYesterday.length) { 471 entries.push({ 472 l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-yesterday"], 473 items: sortItems(visitsFromYesterday), 474 }); 475 } 476 477 // Add visits from this month, grouped by day. 478 visitsByDay.forEach(visits => { 479 entries.push({ 480 l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-this-month"], 481 items: sortItems([...visits]), 482 }); 483 }); 484 485 // Add visits from previous months, grouped by month. 486 visitsByMonth.forEach(visits => { 487 entries.push({ 488 l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-prev-month"], 489 items: sortItems([...visits]), 490 }); 491 }); 492 493 return entries; 494 } 495 496 /** 497 * Get a list of visits sorted by recency. 498 * 499 * @param {HistoryVisit[]} items 500 * @returns {CardEntry[]} 501 */ 502 #getVisitsForLastVisited(items) { 503 return [{ items }]; 504 } 505 506 async #fetchHistory() { 507 return this.placesQuery.getHistory({ 508 daysOld: 60, 509 limit: lazy.maxRowsPref, 510 sortBy: this.sortOption, 511 }); 512 } 513 }