242 lines
6.7 KiB
TypeScript
242 lines
6.7 KiB
TypeScript
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<Item>["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<Item>["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<Value<Item>> {
|
|
const nextId = await nextItemId();
|
|
|
|
const item = await createValue<Item>({
|
|
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<string[]> {
|
|
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<number> {
|
|
// 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<Value<Item> | 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<Item>({
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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();
|
|
}
|