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 const itemKeyPrefix = 'item-'; /** The default storage area to use for {@link Item}s. */ export const storage = browser.storage.sync; /** * 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. * @returns The created {@link Value} with inner {@link Item}. */ export async function createItem( text: Item['text'], url: Item['url'], ): Promise> { const nextId = await nextItemId(); const item = await createValue({ deserialize: deserializeItem, serialize: serializeItem, storage, 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(): Promise { 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(): Promise { // Get all the item keys and sort them so the highest ID is first. const keys = await getItemKeys(); 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> { // Get all the item keys and sort them so the lowest ID is first. const keys = await getItemKeys(); 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, }); }