import browser from "webextension-polyfill"; import {createValue, type Value} from "@holllo/webextension-storage"; /** A queued item. */ export type Item = { /** The date when the item was added. */ dateAdded: Date; /** The unique ID for this item. */ id: number; /** * The display text of the item. * * This can be undefined when the context menu doesn't have access to the text * like when a tab's context menu is used. */ text: string | undefined; /** The URL of the item. */ url: string; }; /** A serialized representation of {@link Item} for use in storage. */ export type SerializedItem = { // Create an index signature with every key from Item and the type for each // as `string`. [k in keyof Item]: string; }; /** The key prefix for {@link Item}s. */ export type ItemKeyPrefix = "item-" | "history-"; /** * Returns the dedicated WebExtension storage area for a given * {@link ItemKeyPrefix}. * * @param prefix The target {@link ItemKeyPrefix}. * @returns The WebExtension storage area. */ export function storageForPrefix( prefix: ItemKeyPrefix, ): browser.Storage.StorageArea { return prefix === "item-" ? browser.storage.sync : browser.storage.local; } /** * Serialize and JSON-stringify an {@link Item}. * * @param input The {@link Item} to serialize. * @returns The serialized {@link Item} string. */ export const serializeItem: Value["serialize"] = ( input: Item, ): string => { const serialized: SerializedItem = { dateAdded: input.dateAdded.toISOString(), id: input.id.toString(), text: input.text ?? "", url: input.url, }; return JSON.stringify(serialized); }; /** * Deserialize and JSON-parse an {@link Item} from a string. * * This function should only ever be used with {@link Value} as this * doesn't do any validation. With {@link Value} it's reasonable to assume * the input will actually deserialize to an {@link Item}. * * @param input The {@link Item} string to deserialize. * @returns The deserialized {@link Item}. */ export const deserializeItem: Value["deserialize"] = ( input: string, ): Item => { const parsed = JSON.parse(input) as SerializedItem; return { dateAdded: new Date(parsed.dateAdded), id: Number(parsed.id), // In `serializeItem()` the item text is set to an empty string when // undefined, so revert it back to undefined here if that's the case. text: parsed.text === "" ? undefined : parsed.text, url: parsed.url, }; }; /** * Create a new {@link Item} in the default storage. * * @param text The text of the {@link Item} to create. * @param url The URL of the {@link Item} to create. * @param itemKeyPrefix The prefix for the {@link Item} key. * @returns The created {@link Value} with inner {@link Item}. */ export async function createItem( text: Item["text"], url: Item["url"], itemKeyPrefix: ItemKeyPrefix = "item-", ): Promise> { const nextId = await nextItemId(); const item = await createValue({ deserialize: deserializeItem, serialize: serializeItem, storage: storageForPrefix(itemKeyPrefix), key: `${itemKeyPrefix}${nextId}`, value: { dateAdded: new Date(), id: nextId, text, url, }, }); return item; } /** * Get all keys from storage that start with the {@link ItemKeyPrefix}. * * @returns The keys as a string array. */ export async function getItemKeys( itemKeyPrefix: ItemKeyPrefix, ): Promise { const storage = storageForPrefix(itemKeyPrefix); const stored = Object.keys(await storage.get()); const keys = stored.filter((key) => key.startsWith(itemKeyPrefix)); return keys; } /** * Get the next unique {@link Item} ID. * * @returns The next ID as a number. */ export async function nextItemId( itemKeyPrefix: ItemKeyPrefix = "item-", ): Promise { // Get all the item keys and sort them so the highest ID is first. const keys = await getItemKeys(itemKeyPrefix); keys.sort((a, b) => b.localeCompare(a)); // Get the first key or use 0 if no items exist yet. const highestKey = keys[0] ?? `${itemKeyPrefix}0`; // Create the next ID by removing the item key prefix and adding 1. return Number(highestKey.slice(itemKeyPrefix.length)) + 1; } /** * Get the next queued {@link Item}. * * @returns The {@link Value} with inner {@link Item} or `undefined` if the * queue is empty. */ export async function nextItem(): Promise | undefined> { const itemKeyPrefix: ItemKeyPrefix = "item-"; // Get all the item keys and sort them so the lowest ID is first. const keys = await getItemKeys(itemKeyPrefix); keys.sort((a, b) => a.localeCompare(b)); // If no keys exist then exit early. const key = keys[0]; if (key === undefined) { return undefined; } return createValue({ deserialize: deserializeItem, key, // We know that an item exists in storage since there is a key for it, which // means passing undefined here is fine as it won't be used. value: undefined!, serialize: serializeItem, storage: storageForPrefix(itemKeyPrefix), }); } /** * Set the WebExtension's badge text to show the current {@link Item} count. */ export async function setBadgeText(): Promise { const itemKeyPrefix: ItemKeyPrefix = "item-"; const keys = await getItemKeys(itemKeyPrefix); const count = keys.length; const action: browser.Action.Static = $browser === "firefox" ? browser.browserAction : browser.action; await action.setBadgeBackgroundColor({color: "#2a2041"}); await action.setBadgeText({text: count === 0 ? "" : count.toString()}); // Only Firefox supports the `setBadgeTextColor` function. if ($browser === "firefox") { action.setBadgeTextColor({color: "#f2efff"}); } } /** * Remove all historical items from local WebExtension storage. */ export async function clearHistory(): Promise { const historyPrefix: ItemKeyPrefix = "history-"; const historyItemKeys = await getItemKeys(historyPrefix); const storage = storageForPrefix(historyPrefix); await storage.remove(historyItemKeys); } /** * Opens the next queued item if one is available, otherwise opens the * WebExtension options page. * * @param newTab Open the next item in a new tab (default `false`). */ export async function openNextItemOrOptionsPage(newTab = false): Promise { const item = await nextItem(); if (item === undefined) { await browser.runtime.openOptionsPage(); return; } const url = item.value.url; await (newTab ? browser.tabs.create({active: true, url}) : browser.tabs.update({url})); await item.remove(); await setBadgeText(); const historyItem = await createItem( item.value.text, item.value.url, "history-", ); await historyItem.save(); }