diff --git a/package.json b/package.json index 63c7a69a..7fcbd02c 100644 --- a/package.json +++ b/package.json @@ -111,8 +111,8 @@ "@fastify/type-provider-typebox": "^3.3.0", "@hyperswarm/secret-stream": "^6.1.2", "@mapeo/crypto": "1.0.0-alpha.10", - "@mapeo/schema": "3.0.0-next.11", - "@mapeo/sqlite-indexer": "1.0.0-alpha.6", + "@mapeo/schema": "3.0.0-next.13", + "@mapeo/sqlite-indexer": "1.0.0-alpha.8", "@sinclair/typebox": "^0.29.6", "b4a": "^1.6.3", "base32.js": "^0.1.0", diff --git a/src/icon-api.js b/src/icon-api.js new file mode 100644 index 00000000..97d94c56 --- /dev/null +++ b/src/icon-api.js @@ -0,0 +1,243 @@ +export const kGetIconBlob = Symbol('getIcon') + +/** @typedef {import('@mapeo/schema').IconValue['variants']} IconVariants */ +/** @typedef {IconVariants[number]} IconVariant */ + +/** + * @typedef {Object} BitmapOpts + * @property {Extract} mimeType + * @property {IconVariant['pixelDensity']} pixelDensity + * @property {IconVariant['size']} size + * + * @typedef {Object} SvgOpts + * @property {Extract} mimeType + * @property {IconVariant['size']} size + */ + +/** @type {{ [mime in IconVariant['mimeType']]: string }} */ +const MIME_TO_EXTENSION = { + 'image/png': '.png', + 'image/svg+xml': '.svg', +} + +export class IconApi { + #projectId + #dataType + #dataStore + #getMediaBaseUrl + + /** + * @param {Object} opts + * @param {import('./datatype/index.js').DataType< + * import('./datastore/index.js').DataStore<'config'>, + * typeof import('./schema/project.js').iconTable, + * 'icon', + * import('@mapeo/schema').Icon, + * import('@mapeo/schema').IconValue + * >} opts.iconDataType + * @param {import('./datastore/index.js').DataStore<'config'>} opts.iconDataStore + * @param {string} opts.projectId + * @param {() => Promise} opts.getMediaBaseUrl + */ + constructor({ iconDataType, iconDataStore, projectId, getMediaBaseUrl }) { + this.#dataType = iconDataType + this.#dataStore = iconDataStore + this.#projectId = projectId + this.#getMediaBaseUrl = getMediaBaseUrl + } + + /** + * @param {object} icon + * @param {import('@mapeo/schema').IconValue['name']} icon.name + * @param {Array<(BitmapOpts | SvgOpts) & { blob: Buffer }>} icon.variants + * + * @returns {Promise} + */ + async create(icon) { + if (icon.variants.length < 1) { + throw new Error('empty variants array') + } + + const savedVariants = await Promise.all( + icon.variants.map(async ({ blob, ...variant }) => { + const blobVersionId = await this.#dataStore.writeRaw(blob) + + return { + ...variant, + blobVersionId, + pixelDensity: + // Pixel density does not apply to svg variants + // TODO: Ideally @mapeo/schema wouldn't require pixelDensity when the mime type is svg + variant.mimeType === 'image/svg+xml' + ? /** @type {const} */ (1) + : variant.pixelDensity, + } + }) + ) + + const { docId } = await this.#dataType.create({ + schemaName: 'icon', + name: icon.name, + variants: savedVariants, + }) + + return docId + } + + /** + * @param {string} iconId + * @param {BitmapOpts | SvgOpts} opts + * + * @returns {Promise} + */ + async [kGetIconBlob](iconId, opts) { + const iconRecord = await this.#dataType.getByDocId(iconId) + const iconVariant = getBestVariant(iconRecord.variants, opts) + const blob = await this.#dataStore.readRaw(iconVariant.blobVersionId) + return blob + } + + /** + * @param {string} iconId + * @param {BitmapOpts | SvgOpts} opts + * + * @returns {Promise} + */ + async getIconUrl(iconId, opts) { + let base = await this.#getMediaBaseUrl() + + if (!base.endsWith('/')) { + base += '/' + } + + base += `${this.#projectId}/${iconId}/` + + const mimeExtension = MIME_TO_EXTENSION[opts.mimeType] + + if (opts.mimeType === 'image/svg+xml') { + return base + `${opts.size}${mimeExtension}` + } + + return base + `${opts.size}@${opts.pixelDensity}x${mimeExtension}` + } +} + +/** + * @type {Record} + */ +const SIZE_AS_NUMERIC = { + small: 1, + medium: 2, + large: 3, +} + +/** + * Given a list of icon variants returns the variant that most closely matches the desired parameters. + * Rules, in order of precedence: + * + * 1. Matching mime type (throw if no matches) + * 2. Matching size. If no exact match: + * 1. If smaller ones exist, prefer closest smaller size. + * 2. Otherwise prefer closest larger size. + * 3. Matching pixel density. If no exact match: + * 1. If smaller ones exist, prefer closest smaller density. + * 2. Otherwise prefer closest larger density. + * + * @param {IconVariants} variants + * @param {BitmapOpts | SvgOpts} opts + */ +export function getBestVariant(variants, opts) { + const { size: wantedSize, mimeType: wantedMimeType } = opts + // Pixel density doesn't matter for svg so default to 1 + const wantedPixelDensity = + opts.mimeType === 'image/svg+xml' ? 1 : opts.pixelDensity + + if (variants.length === 0) { + throw new Error('No variants exist') + } + + const matchingMime = variants.filter((v) => v.mimeType === wantedMimeType) + + if (matchingMime.length === 0) { + throw new Error( + `No variants with desired mime type ${wantedMimeType} exist` + ) + } + + const wantedSizeNum = SIZE_AS_NUMERIC[wantedSize] + + // Sort the relevant variants based on the desired size and pixel density, using the rules of the preference. + // Sorted from closest match to furthest match. + matchingMime.sort((a, b) => { + const aSizeNum = SIZE_AS_NUMERIC[a.size] + const bSizeNum = SIZE_AS_NUMERIC[b.size] + + const aSizeDiff = aSizeNum - wantedSizeNum + const bSizeDiff = bSizeNum - wantedSizeNum + + // Both variants match desired size, use pixel density to determine preferred match + if (aSizeDiff === 0 && bSizeDiff === 0) { + // Pixel density doesn't matter for svg but prefer lower for consistent results + if (opts.mimeType === 'image/svg+xml') { + return a.pixelDensity <= b.pixelDensity ? -1 : 1 + } + + return determineSortValue( + wantedPixelDensity, + a.pixelDensity, + b.pixelDensity + ) + } + + return determineSortValue(wantedSizeNum, aSizeNum, bSizeNum) + }) + + // Closest match will be first element + return matchingMime[0] +} + +/** + * Determines a sort value based on the order of precedence outlined below. Winning value moves closer to front. + * + * 1. Exactly match `target` + * 2. Closest value smaller than `target` + * 3. Closest value larger than `target` + * + * @param {number} target + * @param {number} a + * @param {number} b + * + * @returns {-1 | 0 | 1} + */ +function determineSortValue(target, a, b) { + const aDiff = a - target + const bDiff = b - target + + // Both match exactly, don't change sort order + if (aDiff === 0 && bDiff === 0) { + return 0 + } + + // a matches but b doesn't, prefer a + if (aDiff === 0 && bDiff !== 0) { + return -1 + } + + // b matches but a doesn't, prefer b + if (bDiff === 0 && aDiff !== 0) { + return 1 + } + + // Both are larger than desired, prefer smaller of the two + if (aDiff > 0 && bDiff > 0) { + return a < b ? -1 : 1 + } + + // Both are smaller than desired, prefer larger of the two + if (aDiff < 0 && bDiff < 0) { + return a < b ? 1 : -1 + } + + // Mix of smaller and larger than desired, prefer smaller of the two + return a < b ? -1 : 1 +} diff --git a/tests/icon-api.js b/tests/icon-api.js new file mode 100644 index 00000000..51c736d4 --- /dev/null +++ b/tests/icon-api.js @@ -0,0 +1,685 @@ +// @ts-check +import test from 'brittle' +import RAM from 'random-access-memory' +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' +import { randomBytes } from 'node:crypto' + +import { IconApi, kGetIconBlob, getBestVariant } from '../src/icon-api.js' +import { DataType } from '../src/datatype/index.js' +import { DataStore } from '../src/datastore/index.js' +import { createCoreManager } from './helpers/core-manager.js' +import { iconTable } from '../src/schema/project.js' +import { IndexWriter } from '../src/index-writer/index.js' + +test('create()', async (t) => { + const { iconApi, iconDataType } = setup() + + const expectedName = 'myIcon' + + const bitmapBlob = randomBytes(128) + const svgBlob = randomBytes(128) + + await t.exception(async () => { + return iconApi.create({ name: expectedName, variants: [] }) + }, 'throws when no variants are provided') + + /** @type {Parameters[0]['variants']} */ + const expectedVariants = [ + { + size: 'small', + pixelDensity: 1, + mimeType: 'image/png', + blob: bitmapBlob, + }, + { + size: 'small', + mimeType: 'image/svg+xml', + blob: svgBlob, + }, + ] + + const iconId = await iconApi.create({ + name: expectedName, + variants: expectedVariants, + }) + + t.ok(iconId, 'returns document id') + + const doc = await iconDataType.getByDocId(iconId) + + t.ok(doc, 'icon document created') + t.is(doc.name, expectedName, 'document has expected icon name') + t.is( + doc.variants.length, + expectedVariants.length, + 'document has expected icon name' + ) + + for (const expected of expectedVariants) { + const match = doc.variants.find((v) => { + const mimeTypeMatches = v.mimeType === expected.mimeType + const sizeMatches = v.size === expected.size + + if (expected.mimeType === 'image/svg+xml') { + return mimeTypeMatches && sizeMatches + } + + const pixelDensityMatches = v.pixelDensity === expected.pixelDensity + return mimeTypeMatches && sizeMatches && pixelDensityMatches + }) + + t.ok(match, 'variant is saved') + + // TODO: Do we need to check the blobVersionId field? + } +}) + +test('[kGetIconBlob]()', async (t) => { + const { iconApi } = setup() + + const expectedName = 'myIcon' + + const bitmapBlob = randomBytes(128) + const svgBlob = randomBytes(128) + + /** @type {Parameters[0]['variants']} */ + const expectedVariants = [ + { + size: 'small', + pixelDensity: 1, + mimeType: 'image/png', + blob: bitmapBlob, + }, + { + size: 'large', + mimeType: 'image/svg+xml', + blob: svgBlob, + }, + ] + + const iconId = await iconApi.create({ + name: expectedName, + variants: expectedVariants, + }) + + // Bitmap exact + { + const result = await iconApi[kGetIconBlob](iconId, { + size: 'small', + pixelDensity: 1, + mimeType: 'image/png', + }) + + t.alike(result, bitmapBlob, 'returns expected bitmap blob') + } + + // SVG exact + { + const result = await iconApi[kGetIconBlob](iconId, { + size: 'large', + mimeType: 'image/svg+xml', + }) + + t.alike(result, svgBlob, 'returns expected svg blob') + } + + /// See more extensive non-exact testing in getBestVariant() tests further down + + // Bitmap non-exact + { + const result = await iconApi[kGetIconBlob](iconId, { + size: 'medium', + pixelDensity: 2, + mimeType: 'image/png', + }) + + t.alike(result, bitmapBlob, 'returns expected bitmap blob') + } + + // SVG non-exact + { + const result = await iconApi[kGetIconBlob](iconId, { + size: 'medium', + mimeType: 'image/svg+xml', + }) + + t.alike(result, svgBlob, 'returns expected svg blob') + } +}) + +test(`getIconUrl()`, async (t) => { + let mediaBaseUrl = 'http://127.0.0.1:8080/icons/' + + const { iconApi, projectId } = setup({ + getMediaBaseUrl: async () => mediaBaseUrl, + }) + + const iconId = randomBytes(32).toString('hex') + + { + const url = await iconApi.getIconUrl(iconId, { + size: 'small', + mimeType: 'image/png', + pixelDensity: 1, + }) + + t.is( + url, + mediaBaseUrl + `${projectId}/${iconId}/small@1x.png`, + 'returns expected bitmap icon url' + ) + } + + { + const url = await iconApi.getIconUrl(iconId, { + size: 'small', + mimeType: 'image/svg+xml', + }) + + t.is( + url, + mediaBaseUrl + `${projectId}/${iconId}/small.svg`, + 'returns expected svg icon url' + ) + } + + // Change media base url (e.g. port changes) + mediaBaseUrl = 'http://127.0.0.1:3000/' + + { + const url = await iconApi.getIconUrl(iconId, { + size: 'medium', + mimeType: 'image/png', + pixelDensity: 2, + }) + + t.is( + url, + mediaBaseUrl + `${projectId}/${iconId}/medium@2x.png`, + 'returns expected bitmap icon url after media base url changes' + ) + } + + { + const url = await iconApi.getIconUrl(iconId, { + size: 'large', + mimeType: 'image/svg+xml', + }) + + t.is( + url, + mediaBaseUrl + `${projectId}/${iconId}/large.svg`, + 'returns expected svg icon url after media base url changes' + ) + } +}) + +test('getBestVariant() - no variants exist', (t) => { + t.exception(() => { + return getBestVariant([], { + mimeType: 'image/png', + size: 'small', + pixelDensity: 1, + }) + }, 'throws when no variants exist') +}) + +test('getBestVariant() - specify mimeType', (t) => { + /** @type {Pick} */ + const common = { pixelDensity: 1, size: 'small' } + + const pngVariant = createIconVariant({ + ...common, + mimeType: 'image/png', + }) + + const svgVariant = createIconVariant({ + ...common, + mimeType: 'image/svg+xml', + }) + + t.test('request mime type with match present', (st) => { + /** @type {Array<[import('@mapeo/schema').Icon['variants'][number]['mimeType'], import('@mapeo/schema').Icon['variants'][number]]>} */ + const pairs = [ + ['image/png', pngVariant], + ['image/svg+xml', svgVariant], + ] + + for (const [mimeType, expectedVariant] of pairs) { + const result = getBestVariant([pngVariant, svgVariant], { + ...common, + mimeType, + }) + + st.alike( + result, + getBestVariant([pngVariant, svgVariant].reverse(), { + ...common, + mimeType, + }), + 'same result regardless of variants order' + ) + + st.alike( + result, + expectedVariant, + `returns variant with desired mime type (${mimeType})` + ) + } + }) + + t.test('request a mime type with no match present', (st) => { + st.exception(() => { + getBestVariant([pngVariant], { + ...common, + mimeType: 'image/svg+xml', + }) + }, 'throws when no match for svg exists') + + st.exception(() => { + getBestVariant([svgVariant], { + ...common, + mimeType: 'image/png', + }) + }, 'throws when no match for png exists') + }) +}) + +test('getBestVariant() - specify size', (t) => { + /** @type {Pick} */ + const common = { pixelDensity: 1, mimeType: 'image/png' } + + const smallVariant = createIconVariant({ + ...common, + size: 'small', + }) + + const mediumVariant = createIconVariant({ + ...common, + size: 'medium', + }) + + const largeVariant = createIconVariant({ + ...common, + size: 'large', + }) + + t.test('request size with match present', (st) => { + /** @type {Array<[import('@mapeo/schema').Icon['variants'][number]['size'], import('@mapeo/schema').Icon['variants'][number]]>} */ + const pairs = [ + ['small', smallVariant], + ['medium', mediumVariant], + ['large', largeVariant], + ] + for (const [size, expectedVariant] of pairs) { + const result = getBestVariant( + [smallVariant, mediumVariant, largeVariant], + { ...common, size } + ) + + st.alike( + result, + getBestVariant([smallVariant, mediumVariant, largeVariant].reverse(), { + ...common, + size, + }), + 'same result regardless of variants order' + ) + + st.alike( + result, + expectedVariant, + `returns variant with desired size (${size})` + ) + } + }) + + t.test('request size with only smaller existing', (st) => { + const result = getBestVariant([smallVariant, mediumVariant], { + ...common, + size: 'large', + }) + + st.alike( + result, + getBestVariant([smallVariant, mediumVariant].reverse(), { + ...common, + size: 'large', + }), + 'same result regardless of variants order' + ) + + st.alike(result, mediumVariant, 'returns closest smaller size') + }) + + t.test('request size with both larger and smaller existing', (st) => { + const result = getBestVariant([smallVariant, largeVariant], { + ...common, + size: 'medium', + }) + + st.alike( + result, + getBestVariant([smallVariant, largeVariant].reverse(), { + ...common, + size: 'medium', + }), + 'same result regardless of variants order' + ) + + st.alike(result, smallVariant, 'returns smaller size') + }) + + t.test('request size with only larger existing', (st) => { + const result = getBestVariant([mediumVariant, largeVariant], { + ...common, + size: 'small', + }) + + st.alike( + result, + getBestVariant([mediumVariant, largeVariant].reverse(), { + ...common, + size: 'small', + }), + 'same result regardless of variants order' + ) + + st.alike(result, mediumVariant, 'returns closest larger size') + }) +}) + +test('getBestVariant() - specify pixel density', (t) => { + /** @type {Pick} */ + const common = { size: 'small', mimeType: 'image/png' } + + const density1Variant = createIconVariant({ + ...common, + pixelDensity: 1, + }) + + const density2Variant = createIconVariant({ + ...common, + pixelDensity: 2, + }) + + const density3Variant = createIconVariant({ + ...common, + pixelDensity: 3, + }) + + t.test('request pixel density with match present', (st) => { + /** @type {Array<[import('@mapeo/schema').Icon['variants'][number]['pixelDensity'], import('@mapeo/schema').Icon['variants'][number]]>} */ + const pairs = [ + [1, density1Variant], + [2, density2Variant], + [3, density3Variant], + ] + for (const [pixelDensity, expectedVariant] of pairs) { + const result = getBestVariant( + [density1Variant, density2Variant, density3Variant], + { ...common, pixelDensity } + ) + + st.alike( + result, + getBestVariant( + [density1Variant, density2Variant, density3Variant].reverse(), + { ...common, pixelDensity } + ), + 'same result regardless of variants order' + ) + + st.alike( + result, + expectedVariant, + `returns variant with desired pixel density (${pixelDensity})` + ) + } + }) + + t.test('request pixel density with only smaller existing', (st) => { + const result = getBestVariant([density1Variant, density2Variant], { + ...common, + pixelDensity: 3, + }) + + st.alike( + result, + getBestVariant([density1Variant, density2Variant].reverse(), { + ...common, + pixelDensity: 3, + }), + 'same result regardless of variants order' + ) + + st.alike(result, density2Variant, 'returns closest smaller density') + }) + + t.test( + 'request pixel density with both larger and smaller existing', + (st) => { + const result = getBestVariant([density1Variant, density3Variant], { + ...common, + pixelDensity: 2, + }) + + st.alike( + result, + getBestVariant([density1Variant, density3Variant].reverse(), { + ...common, + pixelDensity: 2, + }), + 'same result regardless of variants order' + ) + + st.alike(result, density1Variant, 'returns smaller density') + } + ) + + t.test('request pixel density with only larger existing', (st) => { + const result = getBestVariant([density2Variant, density3Variant], { + ...common, + pixelDensity: 1, + }) + + st.alike( + result, + getBestVariant([density2Variant, density3Variant].reverse(), { + ...common, + pixelDensity: 1, + }), + 'same result regardless of variants order' + ) + + st.alike(result, density2Variant, 'returns closest larger density') + }) +}) + +test('getBestVariant() - params prioritization', (t) => { + const wantedSizePngVariant = createIconVariant({ + mimeType: 'image/png', + pixelDensity: 1, + size: 'small', + }) + + const wantedPixelDensityPngVariant = createIconVariant({ + mimeType: 'image/png', + pixelDensity: 2, + size: 'medium', + }) + + const wantedSizeSvgVariant = createIconVariant({ + mimeType: 'image/svg+xml', + pixelDensity: 1, + size: 'small', + }) + + const wantedPixelDensitySvgVariant = createIconVariant({ + mimeType: 'image/svg+xml', + pixelDensity: 2, + size: 'medium', + }) + + const result = getBestVariant( + [ + wantedSizePngVariant, + wantedPixelDensityPngVariant, + wantedSizeSvgVariant, + wantedPixelDensitySvgVariant, + ], + { + mimeType: 'image/svg+xml', + size: 'small', + } + ) + + t.alike( + result, + getBestVariant( + [ + wantedSizePngVariant, + wantedPixelDensityPngVariant, + wantedSizeSvgVariant, + wantedPixelDensitySvgVariant, + ].reverse(), + { + mimeType: 'image/svg+xml', + size: 'small', + } + ), + 'same result regardless of variants order' + ) + + t.alike(result, wantedSizeSvgVariant, 'mime type > size > pixel density') +}) + +// TODO: The IconApi doesn't allow creating svg variants with a custom pixel density, so maybe can remove this test? +test('getBestVariant() - svg requests are not affected by pixel density', (t) => { + /** @type {Pick} */ + const common = { size: 'small', mimeType: 'image/svg+xml' } + + const variant1 = createIconVariant({ ...common, pixelDensity: 1 }) + const variant2 = createIconVariant({ ...common, pixelDensity: 2 }) + const variant3 = createIconVariant({ ...common, pixelDensity: 3 }) + + const result = getBestVariant([variant1, variant2, variant3], { + size: 'small', + mimeType: 'image/svg+xml', + }) + + t.alike( + result, + getBestVariant([variant1, variant2, variant3].reverse(), { + mimeType: 'image/svg+xml', + size: 'small', + }), + 'same result regardless of variants order' + ) + + t.alike(result, variant1) +}) + +// TODO: Currently fails. Not sure if we'd run into this situation often in reality +test( + 'getBestVariant - multiple exact matches return deterministic result', + { todo: true }, + (t) => { + const variantA = createIconVariant({ + size: 'small', + pixelDensity: 1, + mimeType: 'image/svg+xml', + }) + const variantB = createIconVariant({ + size: 'small', + pixelDensity: 1, + mimeType: 'image/svg+xml', + }) + + const result = getBestVariant([variantA, variantB], { + size: 'small', + mimeType: 'image/svg+xml', + }) + + t.alike( + result, + getBestVariant([variantA, variantB].reverse(), { + mimeType: 'image/svg+xml', + size: 'small', + }), + 'same result regardless of variants order' + ) + + t.alike(result, variantA) + } +) + +/** + * + * @param {{ getMediaBaseUrl?: () => Promise }} [opts] + */ +function setup({ + getMediaBaseUrl = async () => 'http://127.0.0.1:8080/icons', +} = {}) { + const cm = createCoreManager() + const sqlite = new Database(':memory:') + const db = drizzle(sqlite) + + migrate(db, { + migrationsFolder: new URL('../drizzle/project', import.meta.url).pathname, + }) + + const indexWriter = new IndexWriter({ + tables: [iconTable], + sqlite, + }) + + const iconDataStore = new DataStore({ + namespace: 'config', + coreManager: cm, + storage: () => new RAM(), + batch: async (entries) => indexWriter.batch(entries), + }) + + const iconDataType = new DataType({ + dataStore: iconDataStore, + table: iconTable, + db, + }) + + const projectId = randomBytes(32).toString('hex') + + const iconApi = new IconApi({ + iconDataStore, + iconDataType, + projectId, + getMediaBaseUrl, + }) + + return { + projectId, + iconApi, + iconDataType, + } +} + +function createRandomVersionId(index = 0) { + return randomBytes(32).toString('hex') + `/${index}` +} + +/** + * @param {object} opts + * @param {import('@mapeo/schema').Icon['variants'][number]['size']} opts.size + * @param {import('@mapeo/schema').Icon['variants'][number]['mimeType']} opts.mimeType + * @param {import('@mapeo/schema').Icon['variants'][number]['pixelDensity']} opts.pixelDensity + * + * @returns {import('@mapeo/schema').Icon['variants'][number]} + */ +function createIconVariant(opts) { + return { + ...opts, + blobVersionId: createRandomVersionId(), + } +}