queue/source/item/item.ts

164 lines
4.4 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 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<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.
* @returns The created {@link Value} with inner {@link Item}.
*/
export async function createItem(
text: Item['text'],
url: Item['url'],
): Promise<Value<Item>> {
const nextId = await nextItemId();
const item = await createValue<Item>({
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<string[]> {
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<number> {
// 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<Value<Item> | 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<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,
});
}