Add the new Item handling code.
This commit is contained in:
parent
18e0e06edb
commit
849e443f4e
|
@ -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<Item>, 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,
|
||||||
|
},
|
||||||
|
);
|
|
@ -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<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,
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in New Issue