forked from mirrors/gecko-dev
377 lines
11 KiB
JavaScript
377 lines
11 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/. */
|
|
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
|
|
|
|
const { EventEmitter } = ChromeUtils.import(
|
|
"resource://gre/modules/EventEmitter.jsm"
|
|
);
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs",
|
|
SnapshotScorer: "resource:///modules/SnapshotScorer.sys.mjs",
|
|
Snapshots: "resource:///modules/Snapshots.sys.mjs",
|
|
});
|
|
|
|
XPCOMUtils.defineLazyModuleGetters(lazy, {
|
|
DeferredTask: "resource://gre/modules/DeferredTask.jsm",
|
|
FilterAdult: "resource://activity-stream/lib/FilterAdult.jsm",
|
|
});
|
|
|
|
XPCOMUtils.defineLazyGetter(lazy, "logConsole", function() {
|
|
return console.createInstance({
|
|
prefix: "SnapshotSelector",
|
|
maxLogLevel: Services.prefs.getBoolPref(
|
|
"browser.places.interactions.log",
|
|
false
|
|
)
|
|
? "Debug"
|
|
: "Warn",
|
|
});
|
|
});
|
|
|
|
/**
|
|
* @typedef {object} SelectionContext
|
|
* The necessary context for selecting, filtering and scoring snapshot
|
|
* recommendations. This context is expected to be specific to what the user
|
|
* is doing.
|
|
* @property {number} count
|
|
* The maximum number of recommendations to generate.
|
|
* @property {boolean} filterAdult
|
|
* Whether to filter out adult sites.
|
|
* @property {Map<string, number> | null} sourceWeights
|
|
* Weights for the different recommendation sources. May be null in the
|
|
* event that the new recommendations are disabled.
|
|
* @property {string | undefined} url
|
|
* The page the snapshots are for.
|
|
* @property {PageDataCollector.DATA_TYPE | undefined} type
|
|
* The type of snapshots desired.
|
|
* @property {Function} getCurrentSessionUrls
|
|
* A function that returns a Set containing the urls for the current session.
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} Recommendation
|
|
* The details of a specific recommendation for a snapshot.
|
|
* @property {Snapshot} snapshot
|
|
* The snapshot this recommendation relates to.
|
|
* @property {number} score
|
|
* The score for the snapshot.
|
|
* @property {string | undefined} source
|
|
* The source that provided the largest score for this snapshot.
|
|
*/
|
|
|
|
/**
|
|
* A snapshot selector is responsible for generating a list of snapshots based
|
|
* on the current context. The context initially is just the url of the page
|
|
* being viewed but will evolve to include things like the search terms that
|
|
* brought the user to that page etc.
|
|
*
|
|
* Individual snapshots can be told to rebuild their set of snapshots and a
|
|
* global function is provided that triggers all current selectors to rebuild.
|
|
*
|
|
* The selector is an event emitter that will emit a "snapshots-updated" event
|
|
* when a new list is generated.
|
|
*
|
|
* This component is intentionally decoupled from where the context comes from
|
|
* so it can be unit tested.
|
|
*/
|
|
export class SnapshotSelector extends EventEmitter {
|
|
/**
|
|
* All of the active selectors.
|
|
*/
|
|
static #selectors = new Set();
|
|
|
|
/**
|
|
* Triggers a rebuild of all selectors.
|
|
*/
|
|
static rebuildAll() {
|
|
for (let selector of SnapshotSelector.#selectors) {
|
|
selector.rebuild();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The context should be thought of as the current state for this specific
|
|
* selector. Global state that impacts all selectors should not be kept here.
|
|
*
|
|
* @type {SelectionContext}
|
|
*/
|
|
#context = {
|
|
count: undefined,
|
|
filterAdult: false,
|
|
sourceWeights: null,
|
|
url: undefined,
|
|
time: Date.now(),
|
|
type: undefined,
|
|
getCurrentSessionUrls: undefined,
|
|
};
|
|
|
|
/**
|
|
* A DeferredTask that runs the task to generate snapshots.
|
|
*/
|
|
#task = null;
|
|
|
|
/**
|
|
* @param {object} options
|
|
* @param {number} [options.count]
|
|
* The maximum number of snapshots we ever need to generate. This should not
|
|
* affect the actual snapshots generated and their order but may speed up
|
|
* calculations.
|
|
* @param {boolean} [options.filterAdult]
|
|
* Whether adult sites should be filtered from the snapshots.
|
|
* @param {object | undefined} [options.sourceWeights]
|
|
* Overrides for the weights of different recommendation sources.
|
|
* @param {Function} [options.getCurrentSessionUrls]
|
|
* A function that returns a Set containing the urls for the current session.
|
|
*/
|
|
constructor({
|
|
count = 5,
|
|
filterAdult = false,
|
|
sourceWeights = undefined,
|
|
getCurrentSessionUrls = () => new Set(),
|
|
}) {
|
|
super();
|
|
this.#task = new lazy.DeferredTask(
|
|
() => this.#buildSnapshots().catch(console.error),
|
|
500
|
|
);
|
|
this.#context.count = count;
|
|
this.#context.filterAdult = filterAdult;
|
|
|
|
if (
|
|
sourceWeights ||
|
|
Services.prefs.getBoolPref(
|
|
"browser.pinebuild.snapshots.relevancy.enabled",
|
|
false
|
|
)
|
|
) {
|
|
// Fetch the defaults
|
|
let branch = Services.prefs.getBranch("browser.snapshots.source.");
|
|
let weights = Object.fromEntries(
|
|
branch.getChildList("").map(name => [name, branch.getIntPref(name, 0)])
|
|
);
|
|
|
|
// Apply overrides
|
|
Object.assign(weights, sourceWeights ?? {});
|
|
|
|
this.#context.sourceWeights = new Map(Object.entries(weights));
|
|
}
|
|
|
|
this.#context.getCurrentSessionUrls = getCurrentSessionUrls;
|
|
SnapshotSelector.#selectors.add(this);
|
|
}
|
|
|
|
/**
|
|
* Call to destroy the selector.
|
|
*/
|
|
destroy() {
|
|
this.#task.disarm();
|
|
this.#task.finalize();
|
|
this.#task = null;
|
|
SnapshotSelector.#selectors.delete(this);
|
|
}
|
|
|
|
rebuild() {
|
|
// If this instance has been destroyed then do nothing.
|
|
if (!this.#task) {
|
|
return;
|
|
}
|
|
|
|
this.#task.arm();
|
|
}
|
|
|
|
/**
|
|
* Called internally when the set of snapshots has been generated.
|
|
*
|
|
* @param {Recommendation[]} recommendations
|
|
*/
|
|
#snapshotsGenerated(recommendations) {
|
|
// If this instance has been destroyed then do nothing.
|
|
if (!this.#task) {
|
|
return;
|
|
}
|
|
|
|
lazy.logConsole.debug(
|
|
"Generated recommendations",
|
|
recommendations.map(s => s.snapshot.url)
|
|
);
|
|
this.emit("snapshots-updated", recommendations);
|
|
}
|
|
|
|
/**
|
|
* Starts the process of building snapshots.
|
|
*/
|
|
async #buildSnapshots() {
|
|
if (this.#context.sourceWeights) {
|
|
await this.#buildRelevancySnapshots();
|
|
return;
|
|
}
|
|
|
|
// If this instance has been destroyed then do nothing.
|
|
if (!this.#task) {
|
|
return;
|
|
}
|
|
|
|
// Take a copy of the context to avoid it changing while we are generating
|
|
// the list.
|
|
let context = { ...this.#context };
|
|
lazy.logConsole.debug("Building snapshots", context);
|
|
|
|
// We query for more snapshots than we need so that we can account for
|
|
// deduplicating and filtering out adult sites. This may not catch all
|
|
// cases, but saves the complexity of repeated queries.
|
|
let snapshots = await lazy.Snapshots.query({
|
|
limit: context.count * 4,
|
|
type: context.type,
|
|
});
|
|
|
|
snapshots = snapshots.filter(snapshot => {
|
|
if (snapshot.url == context.url) {
|
|
return false;
|
|
}
|
|
return !context.filterAdult || !lazy.FilterAdult.isAdultUrl(snapshot.url);
|
|
});
|
|
|
|
let recommendations = snapshots.map((snapshot, index) => ({
|
|
source: "recent",
|
|
score: snapshots.length - index,
|
|
snapshot,
|
|
}));
|
|
|
|
recommendations = lazy.SnapshotScorer.dedupeSnapshots(
|
|
recommendations
|
|
).slice(0, context.count);
|
|
|
|
lazy.PlacesUIUtils.insertTitleStartDiffs(
|
|
recommendations.map(s => s.snapshot)
|
|
);
|
|
|
|
this.#snapshotsGenerated(recommendations);
|
|
}
|
|
|
|
/**
|
|
* Build snapshots based on relevancy heuristsics.
|
|
* These include overlapping visits and common referrer, defined by the context options.
|
|
*
|
|
*/
|
|
async #buildRelevancySnapshots() {
|
|
// If this instance has been destroyed then do nothing.
|
|
if (!this.#task) {
|
|
return;
|
|
}
|
|
|
|
// Take a copy of the context to avoid it changing while we are generating
|
|
// the list.
|
|
let context = { ...this.#context };
|
|
lazy.logConsole.debug("Building relevant snapshots", context);
|
|
|
|
let recommendationGroups = await Promise.all(
|
|
Object.entries(lazy.Snapshots.recommendationSources).map(
|
|
async ([key, source]) => {
|
|
let weight = context.sourceWeights.get(key) ?? 0;
|
|
if (weight == 0) {
|
|
return { recommendations: [], weight };
|
|
}
|
|
|
|
let recommendations = await source(context);
|
|
|
|
lazy.logConsole.debug(
|
|
`Found ${key} recommendations:`,
|
|
recommendations.map(
|
|
r =>
|
|
`${r.snapshot.url} (score: ${r.score}${
|
|
r.data ? ", data: " + JSON.stringify(r.data) : ""
|
|
})`
|
|
)
|
|
);
|
|
|
|
return { source: key, recommendations, weight };
|
|
}
|
|
)
|
|
);
|
|
|
|
let recommendations = lazy.SnapshotScorer.combineAndScore(
|
|
context,
|
|
...recommendationGroups
|
|
);
|
|
|
|
recommendations = recommendations.slice(0, context.count);
|
|
|
|
lazy.PlacesUIUtils.insertTitleStartDiffs(
|
|
recommendations.map(r => r.snapshot)
|
|
);
|
|
|
|
this.#snapshotsGenerated(recommendations);
|
|
}
|
|
|
|
/**
|
|
* Update context details and start a rebuild.
|
|
* Undefined properties are ignored, thus pass null to nullify a property.
|
|
*
|
|
* @param {object} context
|
|
* @param {string} [context.url]
|
|
* The url of the current context.
|
|
* @param {number} [context.time]
|
|
* The time, in milliseconds since the Unix epoch.
|
|
* @param {PageDataSchema.DATA_TYPE} [context.type]
|
|
* The type of snapshots for this selector.
|
|
* @param {number} [context.sessionStartTime]
|
|
* The start time of the session, in milliseconds since the Unix epoch.
|
|
* @param {string} [context.rebuildImmediately] (default: false)
|
|
* Whether to rebuild immediately instead of waiting some delay. Useful on
|
|
* startup.
|
|
*/
|
|
updateDetailsAndRebuild({
|
|
url,
|
|
time,
|
|
type,
|
|
sessionStartTime,
|
|
rebuildImmediately = false,
|
|
}) {
|
|
let rebuild = false;
|
|
if (url !== undefined) {
|
|
url = lazy.Snapshots.stripFragments(url);
|
|
if (url != this.#context.url) {
|
|
this.#context.url = url;
|
|
rebuild = true;
|
|
}
|
|
}
|
|
if (time !== undefined && time != this.#context.time) {
|
|
this.#context.time = time;
|
|
rebuild = true;
|
|
}
|
|
if (type !== undefined && type != this.#context.type) {
|
|
this.#context.type = type;
|
|
rebuild = true;
|
|
}
|
|
if (
|
|
sessionStartTime != undefined &&
|
|
sessionStartTime != this.#context.sessionStartTime
|
|
) {
|
|
this.#context.sessionStartTime = sessionStartTime;
|
|
rebuild = true;
|
|
}
|
|
|
|
if (rebuild) {
|
|
if (rebuildImmediately) {
|
|
this.#buildSnapshots();
|
|
} else {
|
|
this.rebuild();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Listen for global events that may affect the snapshots generated.
|
|
Services.obs.addObserver(SnapshotSelector.rebuildAll, "places-snapshots-added");
|
|
Services.obs.addObserver(
|
|
SnapshotSelector.rebuildAll,
|
|
"places-snapshots-deleted"
|
|
);
|
|
Services.obs.addObserver(
|
|
SnapshotSelector.rebuildAll,
|
|
"places-metadata-updated"
|
|
);
|