513 lines
14 KiB
JavaScript
513 lines
14 KiB
JavaScript
/* 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<number, HistoryVisit[]>} 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<string, HistoryVisit[]>.
|
|
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<string, HistoryVisit[]>} oldMap
|
|
* @param {Map<string, HistoryVisit[]>} newMap
|
|
* @returns {Map<string, HistoryVisit[]>}
|
|
*/
|
|
#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<string, HistoryVisit[]>} 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<number, Map<string, HistoryVisit[]>>} 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,
|
|
});
|
|
}
|
|
}
|