/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const lazy = {}; import { getLogger } from "chrome://browser/content/firefoxview/helpers.mjs"; ChromeUtils.defineESModuleGetters(lazy, { PlacesQuery: "resource://gre/modules/PlacesQuery.sys.mjs", PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", }); let XPCOMUtils = ChromeUtils.importESModule( "resource://gre/modules/XPCOMUtils.sys.mjs" ).XPCOMUtils; XPCOMUtils.defineLazyPreferenceGetter( lazy, "maxRowsPref", "browser.firefox-view.max-history-rows", -1 ); const HISTORY_MAP_L10N_IDS = { sidebar: { "history-date-today": "sidebar-history-date-today", "history-date-yesterday": "sidebar-history-date-yesterday", "history-date-this-month": "sidebar-history-date-this-month", "history-date-prev-month": "sidebar-history-date-prev-month", }, firefoxview: { "history-date-today": "firefoxview-history-date-today", "history-date-yesterday": "firefoxview-history-date-yesterday", "history-date-this-month": "firefoxview-history-date-this-month", "history-date-prev-month": "firefoxview-history-date-prev-month", }, }; /** * When sorting by date or site, each card "item" is a single visit. * * When sorting by date *and* site, each card "item" is a mapping of site * domains to their respective list of visits. * * @typedef {HistoryVisit | [string, HistoryVisit[]]} CardItem */ /** * A list of visits displayed on a card. * * @typedef {object} CardEntry * * @property {string} domain * @property {CardItem[]} items * @property {string} l10nId */ export class HistoryController { /** * @type {{ entries: CardEntry[]; searchQuery: string; sortOption: string; }} */ historyCache; host; searchQuery; sortOption; #todaysDate; #yesterdaysDate; constructor(host, options) { this.placesQuery = new lazy.PlacesQuery(); this.searchQuery = ""; this.sortOption = "date"; this.searchResultsLimit = options?.searchResultsLimit || 300; this.component = HISTORY_MAP_L10N_IDS?.[options?.component] ? options?.component : "firefoxview"; this.historyCache = { entries: null, searchQuery: null, sortOption: null, }; this.host = host; host.addController(this); } hostConnected() { this.placesQuery.observeHistory(historyMap => this.updateCache(historyMap)); } hostDisconnected() { this.placesQuery.close(); } deleteFromHistory() { lazy.PlacesUtils.history.remove(this.host.triggerNode.url); } onSearchQuery(e) { this.searchQuery = e.detail.query; this.updateCache(); } onChangeSortOption(e, value = e.target.value) { this.sortOption = value; this.updateCache(); } get historyVisits() { return this.historyCache.entries || []; } get isHistoryPending() { return this.historyCache.entries === null; } get searchResults() { if (this.historyCache.searchQuery && this.historyCache.entries?.length) { return this.historyCache.entries[0].items; } return null; } get totalVisitsCount() { return this.historyVisits.reduce( (count, entry) => count + entry.items.length, 0 ); } get isHistoryEmpty() { return !this.historyVisits.length; } /** * Update cached history. * * @param {CachedHistory} [historyMap] * If provided, performs an update using the given data (instead of fetching * it from the db). */ async updateCache(historyMap) { const { searchQuery, sortOption } = this; const entries = searchQuery ? await this.#getVisitsForSearchQuery(searchQuery) : await this.#getVisitsForSortOption(sortOption, historyMap); if ( this.searchQuery !== searchQuery || this.sortOption !== sortOption || !entries ) { // This query is stale, discard results and do not update the cache / UI. return; } for (const { items } of entries) { for (const item of items) { switch (sortOption) { case "datesite": { // item is a [ domain, visit[] ] entry. const [, visits] = item; for (const visit of visits) { this.#normalizeVisit(visit); } break; } default: // item is a single visit. this.#normalizeVisit(item); } } } this.historyCache = { entries, searchQuery, sortOption }; this.host.requestUpdate(); } /** * Normalize data for fxview-tabs-list. * * @param {HistoryVisit} visit * The visit to format. */ #normalizeVisit(visit) { visit.time = visit.date.getTime(); visit.title = visit.title || visit.url; visit.icon = `page-icon:${visit.url}`; visit.primaryL10nId = "fxviewtabrow-tabs-list-tab"; visit.primaryL10nArgs = JSON.stringify({ targetURI: visit.url, }); visit.secondaryL10nId = "fxviewtabrow-options-menu-button"; visit.secondaryL10nArgs = JSON.stringify({ tabTitle: visit.title || visit.url, }); } async #getVisitsForSearchQuery(searchQuery) { let items = []; try { items = await this.placesQuery.searchHistory( searchQuery, this.searchResultsLimit ); } catch (e) { getLogger("HistoryController").warn( "There is a new search query in progress, so cancelling this one.", e ); } return [{ items }]; } async #getVisitsForSortOption(sortOption, historyMap) { if (!historyMap) { const fetchedHistory = await this.#fetchHistory(); if (!fetchedHistory) { return null; } historyMap = fetchedHistory; } switch (sortOption) { case "date": this.#setTodaysDate(); return this.#getVisitsForDate(historyMap); case "site": return this.#getVisitsForSite(historyMap); case "datesite": this.#setTodaysDate(); return this.#getVisitsForDateSite(historyMap); case "lastvisited": return this.#getVisitsForLastVisited(historyMap); default: return []; } } #setTodaysDate() { const now = new Date(); this.#todaysDate = new Date( now.getFullYear(), now.getMonth(), now.getDate() ); this.#yesterdaysDate = new Date( now.getFullYear(), now.getMonth(), now.getDate() - 1 ); } /** * Get a list of visits, sorted by date, in reverse chronological order. * * @param {Map} historyMap * @returns {CardEntry[]} */ #getVisitsForDate(historyMap) { const entries = []; const visitsFromToday = this.#getVisitsFromToday(historyMap); const visitsFromYesterday = this.#getVisitsFromYesterday(historyMap); const visitsByDay = this.#getVisitsByDay(historyMap); const visitsByMonth = this.#getVisitsByMonth(historyMap); // Add visits from today and yesterday. if (visitsFromToday.length) { entries.push({ l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-today"], items: visitsFromToday, }); } if (visitsFromYesterday.length) { entries.push({ l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-yesterday"], items: visitsFromYesterday, }); } // Add visits from this month, grouped by day. visitsByDay.forEach(visits => { entries.push({ l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-this-month"], items: visits, }); }); // Add visits from previous months, grouped by month. visitsByMonth.forEach(visits => { entries.push({ l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-prev-month"], items: visits, }); }); return entries; } #getVisitsFromToday(cachedHistory) { const mapKey = this.placesQuery.getStartOfDayTimestamp(this.#todaysDate); const visits = cachedHistory.get(mapKey) ?? []; return [...visits]; } #getVisitsFromYesterday(cachedHistory) { const mapKey = this.placesQuery.getStartOfDayTimestamp( this.#yesterdaysDate ); const visits = cachedHistory.get(mapKey) ?? []; return [...visits]; } /** * Get a list of visits per day for each day on this month, excluding today * and yesterday. * * @param {CachedHistory} cachedHistory * The history cache to process. * @returns {CardItem[]} * A list of visits for each day. */ #getVisitsByDay(cachedHistory) { const visitsPerDay = []; for (const [time, visits] of cachedHistory.entries()) { const date = new Date(time); if ( this.#isSameDate(date, this.#todaysDate) || this.#isSameDate(date, this.#yesterdaysDate) ) { continue; } else if (!this.#isSameMonth(date, this.#todaysDate)) { break; } else { visitsPerDay.push(visits); } } return visitsPerDay; } /** * Get a list of visits per month for each month, excluding this one, and * excluding yesterday's visits if yesterday happens to fall on the previous * month. * * @param {CachedHistory} cachedHistory * The history cache to process. * @returns {CardItem[]} * A list of visits for each month. */ #getVisitsByMonth(cachedHistory) { const visitsPerMonth = []; let previousMonth = null; for (const [time, visits] of cachedHistory.entries()) { const date = new Date(time); if ( this.#isSameMonth(date, this.#todaysDate) || this.#isSameDate(date, this.#yesterdaysDate) ) { continue; } const month = this.placesQuery.getStartOfMonthTimestamp(date); if (month !== previousMonth) { visitsPerMonth.push(visits); } else if (this.sortOption === "datesite") { // CardItem type is currently Map. visitsPerMonth[visitsPerMonth.length - 1] = this.#mergeMaps( visitsPerMonth.at(-1), visits ); } else { visitsPerMonth[visitsPerMonth.length - 1] = visitsPerMonth .at(-1) .concat(visits); } previousMonth = month; } return visitsPerMonth; } /** * Given two date instances, check if their dates are equivalent. * * @param {Date} dateToCheck * @param {Date} date * @returns {boolean} * Whether both date instances have equivalent dates. */ #isSameDate(dateToCheck, date) { return ( dateToCheck.getDate() === date.getDate() && this.#isSameMonth(dateToCheck, date) ); } /** * Given two date instances, check if their months are equivalent. * * @param {Date} dateToCheck * @param {Date} month * @returns {boolean} * Whether both date instances have equivalent months. */ #isSameMonth(dateToCheck, month) { return ( dateToCheck.getMonth() === month.getMonth() && dateToCheck.getFullYear() === month.getFullYear() ); } /** * Merge two maps of (domain: string) => HistoryVisit[] into a single map. * * @param {Map} oldMap * @param {Map} newMap * @returns {Map} */ #mergeMaps(oldMap, newMap) { const map = new Map(oldMap); for (const [domain, newVisits] of newMap) { const oldVisits = map.get(domain); map.set(domain, oldVisits?.concat(newVisits) ?? newVisits); } return map; } /** * Get a list of visits, sorted by site, in alphabetical order. * * @param {Map} historyMap * @returns {CardEntry[]} */ #getVisitsForSite(historyMap) { return Array.from(historyMap.entries(), ([domain, items]) => ({ domain, items, l10nId: domain ? null : "firefoxview-history-site-localhost", })).sort((a, b) => a.domain.localeCompare(b.domain)); } /** * Get a list of visits, sorted by date and site, in reverse chronological * order. * * @param {Map>} historyMap * @returns {CardEntry[]} */ #getVisitsForDateSite(historyMap) { const entries = []; const visitsFromToday = this.#getVisitsFromToday(historyMap); const visitsFromYesterday = this.#getVisitsFromYesterday(historyMap); const visitsByDay = this.#getVisitsByDay(historyMap); const visitsByMonth = this.#getVisitsByMonth(historyMap); /** * Sorts items alphabetically by domain name. * * @param {[string, HistoryVisit[]][]} items * @returns {[string, HistoryVisit[]][]} The items in sorted order. */ function sortItems(items) { return items.sort(([aDomain], [bDomain]) => aDomain.localeCompare(bDomain) ); } // Add visits from today and yesterday. if (visitsFromToday.length) { entries.push({ l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-today"], items: sortItems(visitsFromToday), }); } if (visitsFromYesterday.length) { entries.push({ l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-yesterday"], items: sortItems(visitsFromYesterday), }); } // Add visits from this month, grouped by day. visitsByDay.forEach(visits => { entries.push({ l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-this-month"], items: sortItems([...visits]), }); }); // Add visits from previous months, grouped by month. visitsByMonth.forEach(visits => { entries.push({ l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-prev-month"], items: sortItems([...visits]), }); }); return entries; } /** * Get a list of visits sorted by recency. * * @param {HistoryVisit[]} items * @returns {CardEntry[]} */ #getVisitsForLastVisited(items) { return [{ items }]; } async #fetchHistory() { return this.placesQuery.getHistory({ daysOld: 60, limit: lazy.maxRowsPref, sortBy: this.sortOption, }); } }