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