tab-stash/src/model/bookmarks.ts

817 lines
27 KiB
TypeScript

import {computed, reactive, ref, type Ref} from "vue";
import type {Bookmarks} from "webextension-polyfill";
import browser from "webextension-polyfill";
import {
backingOff,
expect,
filterMap,
shortPoll,
tryAgain,
urlToOpen,
type OpenableURL,
} from "../util";
import {trace_fn} from "../util/debug";
import {logErrorsFrom} from "../util/oops";
import {EventWiring} from "../util/wiring";
import {
isChildInParent,
pathTo,
setPosition,
type TreeNode,
type TreeParent,
} from "./tree";
/** A node in the bookmark tree. */
export interface Node extends TreeNode<Folder, Node> {
id: NodeID;
dateAdded?: number;
title: string;
}
export interface Folder extends Node, TreeParent<Folder, Node> {
$stats: FolderStats;
$recursiveStats: FolderStats;
}
export interface Bookmark extends Node {
url: string;
}
export interface Separator extends Node {
type: "separator";
}
export type NodeID = string & {readonly __node_id: unique symbol};
export type NodePosition = {parent: Folder; index: number};
export type FolderStats = {
bookmarkCount: number;
folderCount: number;
};
export function isBookmark(node: Node): node is Bookmark {
return "url" in node;
}
export function isFolder(node: Node): node is Folder {
return "children" in node;
}
export function isSeparator(node: Node): node is Separator {
return "type" in node && node.type === "separator";
}
const trace = trace_fn("bookmarks");
/** The name of the stash root folder. This name must match exactly (including
* in capitalization). */
const STASH_ROOT = "Tab Stash";
const ROOT_FOLDER_HELP =
"https://github.com/josh-berry/tab-stash/wiki/Problems-Locating-the-Tab-Stash-Bookmark-Folder";
/** A Vue model for the state of the browser bookmark tree.
*
* Similar to `tabs.ts`, this model follows the WebExtension conventions, with
* some slight changes to handle hierarchy in the same manner as `tabs.ts`, and
* ensure the state is JSON-serializable.
*/
export class Model {
private readonly by_id = new Map<NodeID, Node>();
private readonly by_url = new Map<OpenableURL, Set<Bookmark>>();
/** The ID of the root node (set only once the model is loaded). */
root_id: NodeID | undefined;
/** The title to look for to locate the stash root. */
readonly stash_root_name: string;
/** A Vue ref to the root folder for Tab Stash's saved tabs. */
readonly stash_root: Ref<Folder | undefined> = ref();
/** If set, there is more than one candidate stash root, and it's not clear
* which one to use. The contents of the warning are an error to show the
* user and a function to direct them to more information. */
readonly stash_root_warning: Ref<
{text: string; help: () => void} | undefined
> = ref();
/** Tracks folders which are candidates to be the stash root, and their
* parents (up to the root). Any changes to these folders should recompute
* the stash root. */
private _stash_root_watch = new Set<Folder>();
/** Are we in the middle of a reload? */
private _is_reloading = false;
/** Did we receive an event since the last (re)load of the model? */
private _event_since_load: boolean = false;
//
// Loading data and wiring up events
//
/** Construct a model by loading bookmarks from the browser bookmark store.
* It will listen for bookmark events to keep itself updated. */
static async from_browser(
stash_root_name_test_only?: string,
): Promise<Model> {
// istanbul ignore if
if (!stash_root_name_test_only) stash_root_name_test_only = STASH_ROOT;
const model = new Model(stash_root_name_test_only);
await model.reload();
return model;
}
private constructor(stash_root_name: string) {
this.stash_root_name = stash_root_name;
const wiring = new EventWiring(this, {
onFired: () => {
this._event_since_load = true;
},
// istanbul ignore next -- safety net; reload the model in the event
// of an unexpected exception.
onError: () => {
logErrorsFrom(() => this.reload());
},
});
wiring.listen(browser.bookmarks.onCreated, this.whenBookmarkCreated);
wiring.listen(browser.bookmarks.onChanged, this.whenBookmarkChanged);
wiring.listen(browser.bookmarks.onMoved, this.whenBookmarkMoved);
wiring.listen(browser.bookmarks.onRemoved, this.whenBookmarkRemoved);
}
dumpState(): any {
return {
root: this.root_id,
stash_root: this.stash_root.value?.id,
bookmarks: JSON.parse(JSON.stringify(Object.fromEntries(this.by_id))),
};
}
/** Fetch bookmarks from the browser again and update the model's
* understanding of the world with the browser's data. Use this if it looks
* like the model has gotten out of sync with the browser (e.g. for crash
* recovery). */
readonly reload = backingOff(async () => {
function mark(marked: Set<string>, root: Bookmarks.BookmarkTreeNode) {
marked.add(root.id);
if (root.children) for (const c of root.children) mark(marked, c);
}
// We loop until we can complete a reload without receiving any
// concurrent events from the browser--if we get a concurrent event, we
// need to try loading again, since we don't know how the event was
// ordered with respect to the getTree().
this._is_reloading = true;
try {
this._event_since_load = true;
while (this._event_since_load) {
this._event_since_load = false;
const tree = await browser.bookmarks.getTree();
const root = tree[0]!;
this.root_id = root.id as NodeID;
this.whenBookmarkCreated(root.id, root);
// Clean up bookmarks that don't exist anymore
const marked = new Set<string>();
mark(marked, root);
for (const id of this.by_id.keys()) {
if (!marked.has(id)) this.whenBookmarkRemoved(id);
}
}
} finally {
this._is_reloading = false;
}
});
//
// Accessors
//
/** Retrieves the node with the specified ID (if it exists). */
node(id: string): Node | undefined {
return this.by_id.get(id as NodeID);
}
/** Retrieves the bookmark with the specified ID. Returns `undefined` if it
* does not exist or is not a bookmark. */
bookmark(id: string): Bookmark | undefined {
const node = this.node(id);
if (node && isBookmark(node)) return node;
return undefined;
}
/** Retrieves the folder with the specified ID. Returns `undefined` if it does not exist or is not a folder. */
folder(id: string): Folder | undefined {
const node = this.node(id);
if (node && isFolder(node)) return node;
return undefined;
}
/** Returns a (reactive) set of bookmarks with the specified URL. */
bookmarksWithURL(url: string): Set<Bookmark> {
let index = this.by_url.get(urlToOpen(url));
if (!index) {
index = reactive(new Set<Bookmark>());
this.by_url.set(urlToOpen(url), index);
}
return index;
}
/** Returns a (non-reactive) array of bookmarks in the stash with the
* specified URL. */
bookmarksWithURLInStash(url: string): Bookmark[] {
return Array.from(this.bookmarksWithURL(url)).filter(bm =>
this.isNodeInStashRoot(bm),
);
}
isParent(node: Node): node is Folder {
return isFolder(node);
}
/** Check if `node` is contained, directly or indirectly, by the stash root.
* If there is no stash root, always returns `false`. */
isNodeInStashRoot(node: Node): boolean {
// istanbul ignore if -- we always have a root in tests
if (!this.stash_root.value) return false;
return isChildInParent(node, this.stash_root.value);
}
/** Returns true if a particular URL is present in the stash. */
isURLStashed(url: string): boolean {
const stash_root = this.stash_root.value;
// istanbul ignore if -- uncommon and hard to test
if (!stash_root) return false;
for (const bm of this.bookmarksWithURL(url)) {
if (this.isNodeInStashRoot(bm)) return true;
}
return false;
}
/** Given a URL, find and return all the folders under the stash root which
* contain bookmarks with that URL. (This is used by the UI to show "Stashed
* in ..." tooltips on tabs.) */
foldersInStashContainingURL(url: string): Folder[] {
const stash_root = this.stash_root.value;
// istanbul ignore if -- uncommon and hard to test
if (!stash_root) return [];
const ret: Folder[] = [];
for (const bm of this.bookmarksWithURL(url)) {
const parent = bm.position?.parent;
// istanbul ignore next -- should never happen in practice; it's not
// possible to place bookmarks directly under the root folder
if (!parent) continue;
if (!isChildInParent(parent as Node, stash_root)) continue;
ret.push(parent);
}
return ret;
}
/** Return all the URLs present in the stash root. */
urlsInStash(): Set<string> {
const urls = new Set<string>();
const urlsInChildren = (folder: Folder) => {
for (const c of folder.children) {
if (isBookmark(c)) urls.add(c.url);
else if (isFolder(c)) urlsInChildren(c);
}
};
if (this.stash_root.value) urlsInChildren(this.stash_root.value);
return urls;
}
//
// Mutators
//
/** Creates a bookmark and waits for the model to reflect the creation.
* Returns the bookmark node in the model. */
async create(bm: browser.Bookmarks.CreateDetails): Promise<Node> {
const ret = await browser.bookmarks.create(bm);
return await shortPoll(() => {
const bm = this.by_id.get(ret.id as NodeID);
if (!bm) tryAgain();
return bm;
});
}
/** Updates a bookmark's title and waits for the model to reflect the
* update. */
async rename(bm: Bookmark | Folder, title: string): Promise<void> {
await browser.bookmarks.update(bm.id, {title});
await shortPoll(() => {
if (bm.title !== title) tryAgain();
});
}
/** Deletes a bookmark and waits for the model to reflect the deletion.
*
* If the node is part of the stash and belongs to an unnamed folder which
* is now empty, cleanup that folder as well.
*/
async remove(id: NodeID): Promise<void> {
const node = this.node(id);
if (!node) return;
const pos = node.position;
await browser.bookmarks.remove(id);
// Wait for the model to catch up
await shortPoll(() => {
// Wait for the model to catch up
if (this.by_id.has(id)) tryAgain();
});
if (pos) await this.maybeCleanupEmptyFolder(pos.parent);
}
/** Deletes an entire tree of bookmarks and waits for the model to reflect
* the deletion. */
async removeTree(id: NodeID): Promise<void> {
await browser.bookmarks.removeTree(id);
// Wait for the model to catch up
await shortPoll(() => {
// Wait for the model to catch up
if (this.by_id.has(id)) tryAgain();
});
}
/** Moves a bookmark such that it precedes the item with index `toIndex` in
* the destination folder. (You can pass an index `>=` the length of the
* bookmark folder's children to move the item to the end of the folder.)
*
* Use this instead of `browser.bookmarks.move()`, which behaves differently
* in Chrome and Firefox... */
async move(id: NodeID, toParent: NodeID, toIndex: number): Promise<void> {
// Firefox's `index` parameter behaves like the bookmark is first
// removed, then re-added. Chrome's/Edge's behaves like the bookmark is
// first added, then removed from its old location, so the index of the
// item after the move will sometimes be toIndex-1 instead of toIndex;
// we account for this below.
const node = expect(this.node(id), () => `No such bookmark node: ${id}`);
const position = expect(
node.position,
() => `Unable to locate node ${id} in its parent`,
);
// Clamp the destination index based on the model length, or the poll
// below won't see the index it's expecting. (This isn't 100%
// reliable--we might still get an exception if multiple concurrent
// moves are going on, but even Firefox itself has bugs in this
// situation, soooo... *shrug*)
const toParentFolder = expect(
this.folder(toParent),
() => `Unable to locate destination folder: ${toParent}`,
);
toIndex = Math.min(toParentFolder.children.length, Math.max(0, toIndex));
// istanbul ignore else
if (!!browser.runtime.getBrowserInfo) {
// We're using Firefox
if (position.parent.id === toParent) {
if (toIndex > position.index) toIndex--;
}
}
await browser.bookmarks.move(id, {parentId: toParent, index: toIndex});
await shortPoll(() => {
const pos = node.position;
if (!pos) tryAgain();
if (pos.parent.id !== toParent || pos.index !== toIndex) tryAgain();
});
await this.maybeCleanupEmptyFolder(position.parent);
}
/** Find and return the stash root, or create one if it doesn't exist. */
async ensureStashRoot(): Promise<Folder> {
if (this.stash_root.value) return this.stash_root.value;
await this.create({title: this.stash_root_name});
// GROSS HACK to avoid creating duplicate roots follows.
//
// We sample at irregular intervals for a bit to see if any other models
// are trying to create the stash root at the same time we are. If so,
// this sampling gives us a higher chance to observe each other. But if
// we consistently see a single candidate over time, we can assume we're
// the only one running right now.
const start = Date.now();
let delay = 10;
let candidates = this._maybeUpdateStashRoot();
while (Date.now() - start < delay) {
if (candidates.length > 1) {
// If we find MULTIPLE candidates so soon after finding NONE,
// there must be multiple threads trying to create the root
// folder. Let's try to remove one. We are guaranteed that all
// threads see the same ordering of candidate folders (and thus
// will all choose the same folder to save) because the
// candidate list is sorted deterministically.
await this.remove(candidates[1].id).catch(() => {});
delay += 10;
}
await new Promise(r => setTimeout(r, 5 * Math.random()));
candidates = this._maybeUpdateStashRoot();
}
// END GROSS HACK
return candidates[0];
}
/** Create a new folder at the top of the stash root (creating the stash
* root itself if it does not exist). If the name is not specified, a
* default name will be assigned based on the folder's creation time. */
async createStashFolder(
name?: string,
parent?: NodeID,
position?: "top" | "bottom",
): Promise<Folder> {
const stash_root = await this.ensureStashRoot();
parent ??= stash_root.id;
position ??= "top";
const bm = await this.create({
parentId: parent,
title: name ?? genDefaultFolderName(new Date()),
// !-cast: this.create() will check the existence of the parent for us
index: position === "top" ? 0 : this.folder(parent)!.children.length,
});
return bm as Folder;
}
/** Removes the folder if it is empty, unnamed and within the stash root. */
private async maybeCleanupEmptyFolder(folder: Folder) {
// Folder does not have a default/unnamed-shape name
if (getDefaultFolderNameISODate(folder.title) === null) return;
if (folder.children.length > 0) return;
if (!this.stash_root.value) return;
if (!isChildInParent(folder as Node, this.stash_root.value)) return;
// NOTE: This will never be recursive because remove() only calls us if
// we're removing a leaf node, which we are never doing here.
//
// ALSO NOTE: If the folder is suddenly NOT empty due to a race, stale
// model, etc., this will fail, because the browser itself will throw.
await this.remove(folder.id);
}
//
// Events which are detected automatically by this model; these can be
// called for testing purposes but otherwise you can ignore them.
//
// (In contrast to onFoo-style things, they are event listeners, not event
// senders.)
//
whenBookmarkCreated(id: string, new_bm: Bookmarks.BookmarkTreeNode) {
// istanbul ignore next -- this is kind of a dumb/redundant API, but it
// must conform to browser.bookmarks.onCreated...
if (id !== new_bm.id) throw new Error(`Bookmark IDs don't match`);
trace("whenBookmarkCreated", new_bm);
const nodeId = id as NodeID;
const parentId = (new_bm.parentId ?? "") as NodeID;
// The parent must already exist and be a folder
const parent = this.folder(parentId);
if (parentId && !parent) {
// We ignore missing parents if we're reloading--this is probably an event
// for a child whose parent we haven't processed yet, and we'll just pick
// it up next time around.
if (this._is_reloading) return;
throw new Error(
`Trying to create a non-root bookmark "${id}" whose parent "${parentId}" doesn't exist`,
);
}
let node = this.by_id.get(nodeId);
if (!node) {
if (isBrowserBTNFolder(new_bm)) {
const folder: Folder = reactive({
position: undefined,
id: nodeId,
dateAdded: new_bm.dateAdded,
title: new_bm.title ?? "",
children: [],
$stats: computed(() => {
let bookmarkCount = 0;
let folderCount = 0;
for (const c of folder.children) {
if (isFolder(c)) ++folderCount;
if (isBookmark(c)) ++bookmarkCount;
}
return {bookmarkCount, folderCount};
}),
$recursiveStats: computed(() => {
let bookmarkCount = folder.$stats.bookmarkCount;
let folderCount = folder.$stats.folderCount;
for (const c of folder.children) {
if (!isFolder(c)) continue;
const stats = c.$recursiveStats;
bookmarkCount += stats.bookmarkCount;
folderCount += stats.folderCount;
}
return {bookmarkCount, folderCount};
}),
});
node = folder;
} else if (new_bm.type === "separator") {
node = reactive({
position: undefined,
id: nodeId,
dateAdded: new_bm.dateAdded,
type: "separator" as "separator",
title: "" as "",
});
} else {
node = reactive({
position: undefined,
id: nodeId,
dateAdded: new_bm.dateAdded,
title: new_bm.title ?? "",
url: new_bm.url ?? "",
});
this._add_url(node as Bookmark);
}
this.by_id.set(node.id, node);
} else {
// For idempotency, if the bookmark already exists, we merge the new
// info we got with the existing record.
node.title = new_bm.title;
if (isBookmark(node)) {
this._remove_url(node);
node.url = new_bm.url ?? "";
this._add_url(node);
}
node.dateAdded = new_bm.dateAdded;
}
// Move ourselves to the correct position in the tree.
setPosition(node, parent ? {parent, index: new_bm.index!} : undefined);
// If we got children, bring them in as well.
if (isBrowserBTNFolder(new_bm) && new_bm.children) {
for (const child of new_bm.children) {
this.whenBookmarkCreated(child.id, child);
}
}
// Finally, see if this folder is a candidate for being a stash root.
if (isFolder(node)) {
if (node.title === this.stash_root_name) this._stash_root_watch.add(node);
if (this._stash_root_watch.has(node)) this._maybeUpdateStashRoot();
}
}
whenBookmarkChanged(id: string, info: Bookmarks.OnChangedChangeInfoType) {
trace("whenBookmarkChanged", id, info);
if (this._is_reloading) return;
const node = expect(
this.by_id.get(id as NodeID),
() => `Got change event for unknown node ${id}: ${JSON.stringify(info)}`,
);
if ("title" in info) {
node.title = info.title;
if (isFolder(node) && node.title === this.stash_root_name) {
this._stash_root_watch.add(node);
}
}
if (info.url !== undefined && isBookmark(node)) {
this._remove_url(node);
node.url = info.url;
this._add_url(node);
}
// If this bookmark has been renamed to != this.stash_root_name,
// _maybeUpdateStashRoot() will remove it from the watch set
// automatically.
if (isFolder(node) && this._stash_root_watch.has(node)) {
this._maybeUpdateStashRoot();
}
}
whenBookmarkMoved(id: string, info: {parentId: string; index: number}) {
trace("whenBookmarkMoved", id, info);
if (this._is_reloading) return;
const node = expect(
this.by_id.get(id as NodeID),
() => `Got move event for unknown node ${id}: ${JSON.stringify(info)}`,
);
const new_parent = expect(
this.folder(info.parentId as NodeID),
() => `Move of ${id} is going to unknown folder ${info.parentId}`,
);
setPosition(node, {parent: new_parent, index: info.index});
if (isFolder(node) && this._stash_root_watch.has(node)) {
this._maybeUpdateStashRoot();
}
}
whenBookmarkRemoved(id: string) {
trace("whenBookmarkRemoved", id);
if (this._is_reloading) return;
const node = this.by_id.get(id as NodeID);
if (!node) return;
// We must remove children before their parents, so that we never have a
// child referencing a parent that doesn't exist.
if (isFolder(node)) {
for (const c of Array.from(node.children)) this.whenBookmarkRemoved(c.id);
}
setPosition(node, undefined);
this.by_id.delete(node.id);
if (isBookmark(node)) this._remove_url(node);
if (isFolder(node) && this._stash_root_watch.has(node)) {
// We must explicitly remove `node` here because its title is
// still "Tab Stash" even after it is deleted.
this._stash_root_watch.delete(node);
this._maybeUpdateStashRoot();
}
}
/** Update `this.stash_root` if appropriate. This function tries to be
* fairly efficient in most cases since it is expected to be called quite
* frequently.
*
* We avoid using watch() or watchEffect() here because we have to inspect
* quite a few objects (starting from the root) to determine the stash root,
* and so we want to minimize when this search is actually done. */
private _maybeUpdateStashRoot(): Folder[] {
// Collect the current candidate set from the watch, limiting ourselves
// only to actual candidates (folders with the right name).
let candidates = Array.from(this._stash_root_watch).filter(
bm => bm.children && bm.title === this.stash_root_name,
);
// Find the path from each candidate to the root, and make sure we're
// watching the whole path (so if a parent gets moved, we get called
// again).
const paths = filterMap(candidates, c => ({
folder: c,
path: pathTo<Folder, Node>(c),
}));
this._stash_root_watch = new Set(candidates);
for (const p of paths) {
for (const pos of p.path) this._stash_root_watch.add(pos.parent);
}
// Find the depth of the candidate closest to the root.
const depth = Math.min(...paths.map(p => p.path.length));
// Filter out candidates that are deeper than the minimum depth, and
// sort the remainder in a stable fashion according to their creation
// date and ID.
candidates = paths
.filter(p => p.path.length <= depth)
.map(p => p.folder)
.sort((a, b) => {
const byDate = (a.dateAdded ?? 0) - (b.dateAdded ?? 0);
if (byDate !== 0) return byDate;
if (a.id < b.id) return -1;
if (a.id > b.id) return 1;
return 0;
});
// The actual stash root is the first candidate. The if-statement ensures
// we only update the stash root if it's actually changed, since updating it
// can be quite expensive (it will effectively redraw the whole UI).
if (this.stash_root.value !== candidates[0]) {
this.stash_root.value = candidates[0];
}
// But if we have multiple candidates, we need to raise the alarm that
// there is an ambiguity the user should resolve.
if (candidates.length > 1) {
this.stash_root_warning.value = {
text:
`You have multiple "${this.stash_root_name}" bookmark ` +
`folders, and Tab Stash isn't sure which one to use. ` +
`Click here to find out how to resolve the issue.`,
/* istanbul ignore next */
help: () => browser.tabs.create({active: true, url: ROOT_FOLDER_HELP}),
};
} else {
this.stash_root_warning.value = undefined;
}
// We return the candidate list here so that callers can see what
// candidates are available (since there may be ways of resolving
// conflicts we can't do here).
return candidates;
}
private _add_url(bm: Bookmark) {
this.bookmarksWithURL(bm.url).add(bm);
}
private _remove_url(bm: Bookmark) {
const index = this.by_url.get(urlToOpen(bm.url));
// istanbul ignore if -- internal consistency
if (!index) return;
index.delete(bm);
}
/** Create a bunch of fake(-ish) tabs for benchmarking purposes. This is
* private because no actual code should call this, but we want it accessible
* at runtime. */
async createTabsForBenchmarks_testonly(options: {
name?: string;
folder_count: number;
folder_levels: number;
tabs_per_folder: number;
}): Promise<void> {
const bench_folder = await this.createStashFolder(
options.name ?? "Fake Tabs",
);
const populate_folder = async (
parent: Folder,
levels: number,
path: string,
) => {
if (levels > 0) {
for (let i = 0; i < options.folder_count; ++i) {
const f = await this.createStashFolder(undefined, parent.id);
await populate_folder(f, levels - 1, `${path}-${i}`);
}
} else {
for (let i = 0; i < options.tabs_per_folder; ++i) {
await this.create({
title: `Fake Tab #${i}`,
url: `http://example.com/#${path}-${i}`,
parentId: parent.id,
index: i,
});
}
}
};
await populate_folder(bench_folder, options.folder_levels, "root");
}
}
//
// Public helper functions for dealing with folders under the stash root
//
/** Given a folder name, check if it's an "default"-shaped folder name (i.e.
* just a timestamp) and return the timestamp portion of the name if so. */
export function getDefaultFolderNameISODate(n: string): string | null {
let m = n.match(/saved-([-0-9:.T]+Z)/);
return m ? m[1] : null;
}
/** Generate a "default"-shaped folder name from a timestamp (usually the
* timestamp of its creation). */
export function genDefaultFolderName(date: Date): string {
return `saved-${date.toISOString()}`;
}
/** Given a folder name as it appears in the bookmarks tree, return a "friendly"
* version to show to the user. This translates folder names that look like a
* "default"-shaped folder name into a user-friendly string, if applicable. */
export function friendlyFolderName(name: string): string {
const folderDate = getDefaultFolderNameISODate(name);
if (folderDate) return `Saved ${new Date(folderDate).toLocaleString()}`;
return name;
}
//
// Helper functions for the model
//
/** A cross-browser compatible way to tell if a bookmark returned by the
* `browser.bookmarks` API is a folder or not. */
function isBrowserBTNFolder(bm: Bookmarks.BookmarkTreeNode): boolean {
if (bm.type === "folder") return true; // for Firefox
if (bm.children) return true; // for Chrome (sometimes)
if (!("type" in bm) && !("url" in bm)) return true; // for Chrome
return false;
}