diff --git a/source/item/item.test.ts b/source/item/item.test.ts new file mode 100644 index 0000000..c69320e --- /dev/null +++ b/source/item/item.test.ts @@ -0,0 +1,97 @@ +import {type TestContext, setup} from '@holllo/test'; +import {type Value} from '@holllo/webextension-storage'; +import browser from 'webextension-polyfill'; + +import {type Item, createItem, nextItem, nextItemId, storage} from './item.js'; + +const testText = 'Test Item'; +const testUrl = 'https://example.org/'; + +/** + * Check all properties of an {@link Item}. + * + * @param item The {@link Item} to assert. + * @param test The {@link TestContext} for the assertions. + */ +function assertItem(item: Value, test: TestContext): void { + // Assert that itemKeyPrefix is used. + test.true(/^item-\d+$/.test(item.key), 'item key regex'); + + // Assert that deserialization instantiates any classes. + test.true(item.value.dateAdded instanceof Date, 'dateAdded is a Date'); + + // Assert that the expected values are indeed present. + test.true(item.value.id > 0, 'id is set'); + test.equals(item.value.text, testText, 'text is set'); + test.equals(item.value.url, testUrl, 'url is set'); +} + +await setup( + 'Item', + async (group) => { + group.beforeAll(async () => { + // If we're in production and testing, clear item storage. + if (!$dev && $test) { + await storage.clear(); + } + }); + + group.test('create & nextItem', async (test) => { + const testItem = await createItem(testText, testUrl); + assertItem(testItem, test); + await testItem.save(); + + // Make sure `nextItem()` returns an item. + let storedNext = await nextItem(); + if (storedNext === undefined) { + throw new Error('Expected an item'); + } + + // Assert that our first test item and the stored one are identical. + test.equals(storedNext.key, testItem.key, 'id check'); + assertItem(storedNext, test); + + // Store all test items we create so we can remove them later on. + const items = [testItem]; + + // Create a bunch of test items and assert them all. + for (let index = 1; index < 10; index++) { + const next = await createItem(testText, testUrl); + test.equals(testItem.value.id + index, next.value.id, 'id check'); + assertItem(next, test); + items.push(next); + await next.save(); + } + + // Remove all test items. + await Promise.all(items.map(async (item) => item.remove())); + + // After all items have been removed test that `nextItem` returns nothing. + // This test will fail if an item is left from development, to clear + // storage automatically run in production and have test enabled. + // ie. `NODE_ENV=production TEST=true makers dev` + // TODO: Temporarily store existing storage and run the tests, and then + // restore it again. + storedNext = await nextItem(); + test.equals(storedNext, undefined, 'next item is undefined'); + }); + + group.test('nextItemId', async (test) => { + const testItem = await createItem(testText, testUrl); + assertItem(testItem, test); + await testItem.save(); + + const id = await nextItemId(); + test.equals(typeof id, 'number', 'id is a number'); + test.false(Number.isNaN(id), 'id is not NaN'); + test.true(id > 0, 'id larger than 0'); + test.equals(await nextItemId(), testItem.value.id + 1, 'id check'); + await testItem.remove(); + }); + }, + { + // Run tests in series since we're using WebExtension storage to test stuff + // and don't want the ID checks to interfere with one another. + parallel: false, + }, +); diff --git a/source/item/item.ts b/source/item/item.ts new file mode 100644 index 0000000..ca0c268 --- /dev/null +++ b/source/item/item.ts @@ -0,0 +1,163 @@ +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, + }); +}