Files
mastodon-sakyey/app/javascript/mastodon/features/emoji/database.ts

229 lines
6.1 KiB
TypeScript
Raw Normal View History

2025-07-09 11:55:41 +02:00
import { SUPPORTED_LOCALES } from 'emojibase';
2025-07-22 16:43:15 +02:00
import type { Locale } from 'emojibase';
import type { DBSchema, IDBPDatabase } from 'idb';
2025-07-09 11:55:41 +02:00
import { openDB } from 'idb';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
2025-07-22 16:43:15 +02:00
import type {
CustomEmojiData,
UnicodeEmojiData,
LocaleOrCustom,
} from './types';
2025-07-31 19:30:14 +02:00
import { emojiLogger } from './utils';
2025-07-09 11:55:41 +02:00
interface EmojiDB extends LocaleTables, DBSchema {
custom: {
key: string;
2025-07-22 16:43:15 +02:00
value: CustomEmojiData;
2025-07-09 11:55:41 +02:00
indexes: {
category: string;
};
};
etags: {
key: LocaleOrCustom;
value: string;
};
}
interface LocaleTable {
key: string;
2025-07-22 16:43:15 +02:00
value: UnicodeEmojiData;
2025-07-09 11:55:41 +02:00
indexes: {
group: number;
label: string;
order: number;
tags: string[];
};
}
type LocaleTables = Record<Locale, LocaleTable>;
2025-07-31 19:30:14 +02:00
type Database = IDBPDatabase<EmojiDB>;
2025-07-09 11:55:41 +02:00
const SCHEMA_VERSION = 1;
2025-07-31 19:30:14 +02:00
const loadedLocales = new Set<Locale>();
2025-07-09 11:55:41 +02:00
2025-07-31 19:30:14 +02:00
const log = emojiLogger('database');
// Loads the database in a way that ensures it's only loaded once.
const loadDB = (() => {
let dbPromise: Promise<Database> | null = null;
// Actually load the DB.
async function initDB() {
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
upgrade(database) {
const customTable = database.createObjectStore('custom', {
keyPath: 'shortcode',
2025-07-22 16:43:15 +02:00
autoIncrement: false,
});
2025-07-31 19:30:14 +02:00
customTable.createIndex('category', 'category');
database.createObjectStore('etags');
for (const locale of SUPPORTED_LOCALES) {
const localeTable = database.createObjectStore(locale, {
keyPath: 'hexcode',
autoIncrement: false,
});
localeTable.createIndex('group', 'group');
localeTable.createIndex('label', 'label');
localeTable.createIndex('order', 'order');
localeTable.createIndex('tags', 'tags', { multiEntry: true });
}
},
});
await syncLocales(db);
return db;
}
// Loads the database, or returns the existing promise if it hasn't resolved yet.
const loadPromise = async (): Promise<Database> => {
if (dbPromise) {
return dbPromise;
}
dbPromise = initDB();
return dbPromise;
};
// Special way to reset the database, used for unit testing.
loadPromise.reset = () => {
dbPromise = null;
};
return loadPromise;
})();
2025-07-22 16:43:15 +02:00
export async function putEmojiData(emojis: UnicodeEmojiData[], locale: Locale) {
2025-07-31 19:30:14 +02:00
loadedLocales.add(locale);
2025-07-22 16:43:15 +02:00
const db = await loadDB();
2025-07-09 11:55:41 +02:00
const trx = db.transaction(locale, 'readwrite');
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
await trx.done;
}
2025-07-22 16:43:15 +02:00
export async function putCustomEmojiData(emojis: CustomEmojiData[]) {
const db = await loadDB();
2025-07-09 11:55:41 +02:00
const trx = db.transaction('custom', 'readwrite');
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
await trx.done;
}
2025-07-22 16:43:15 +02:00
export async function putLatestEtag(etag: string, localeString: string) {
2025-07-09 11:55:41 +02:00
const locale = toSupportedLocaleOrCustom(localeString);
2025-07-22 16:43:15 +02:00
const db = await loadDB();
2025-07-31 19:30:14 +02:00
await db.put('etags', etag, locale);
2025-07-09 11:55:41 +02:00
}
2025-07-31 19:30:14 +02:00
export async function loadEmojiByHexcode(
2025-07-22 16:43:15 +02:00
hexcode: string,
localeString: string,
) {
const db = await loadDB();
2025-07-31 19:30:14 +02:00
const locale = toLoadedLocale(localeString);
2025-07-09 11:55:41 +02:00
return db.get(locale, hexcode);
}
2025-07-22 16:43:15 +02:00
export async function searchEmojisByHexcodes(
hexcodes: string[],
localeString: string,
) {
const db = await loadDB();
2025-07-31 19:30:14 +02:00
const locale = toLoadedLocale(localeString);
const sortedCodes = hexcodes.toSorted();
const results = await db.getAll(
2025-07-22 16:43:15 +02:00
locale,
2025-07-31 19:30:14 +02:00
IDBKeyRange.bound(sortedCodes.at(0), sortedCodes.at(-1)),
2025-07-22 16:43:15 +02:00
);
2025-07-31 19:30:14 +02:00
return results.filter((emoji) => hexcodes.includes(emoji.hexcode));
2025-07-22 16:43:15 +02:00
}
2025-07-31 19:30:14 +02:00
export async function searchEmojisByTag(tag: string, localeString: string) {
2025-07-22 16:43:15 +02:00
const db = await loadDB();
2025-07-31 19:30:14 +02:00
const locale = toLoadedLocale(localeString);
const range = IDBKeyRange.bound(
tag.toLowerCase(),
`${tag.toLowerCase()}\uffff`,
);
2025-07-09 11:55:41 +02:00
return db.getAllFromIndex(locale, 'tags', range);
}
2025-07-31 19:30:14 +02:00
export async function loadCustomEmojiByShortcode(shortcode: string) {
2025-07-22 16:43:15 +02:00
const db = await loadDB();
2025-07-09 11:55:41 +02:00
return db.get('custom', shortcode);
}
2025-07-22 16:43:15 +02:00
export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
const db = await loadDB();
2025-07-31 19:30:14 +02:00
const sortedCodes = shortcodes.toSorted();
const results = await db.getAll(
2025-07-22 16:43:15 +02:00
'custom',
2025-07-31 19:30:14 +02:00
IDBKeyRange.bound(sortedCodes.at(0), sortedCodes.at(-1)),
2025-07-22 16:43:15 +02:00
);
2025-07-31 19:30:14 +02:00
return results.filter((emoji) => shortcodes.includes(emoji.shortcode));
2025-07-22 16:43:15 +02:00
}
2025-07-09 11:55:41 +02:00
export async function loadLatestEtag(localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString);
2025-07-22 16:43:15 +02:00
const db = await loadDB();
2025-07-09 11:55:41 +02:00
const rowCount = await db.count(locale);
if (!rowCount) {
return null; // No data for this locale, return null even if there is an etag.
}
const etag = await db.get('etags', locale);
return etag ?? null;
}
2025-07-31 19:30:14 +02:00
// Private functions
async function syncLocales(db: Database) {
const locales = await Promise.all(
SUPPORTED_LOCALES.map(
async (locale) =>
[locale, await hasLocale(locale, db)] satisfies [Locale, boolean],
),
);
for (const [locale, loaded] of locales) {
if (loaded) {
loadedLocales.add(locale);
} else {
loadedLocales.delete(locale);
}
}
log('Loaded %d locales: %o', loadedLocales.size, loadedLocales);
}
function toLoadedLocale(localeString: string) {
const locale = toSupportedLocale(localeString);
if (localeString !== locale) {
log(`Locale ${locale} is different from provided ${localeString}`);
}
if (!loadedLocales.has(locale)) {
2025-09-30 15:06:02 +02:00
throw new LocaleNotLoadedError(locale);
2025-07-31 19:30:14 +02:00
}
return locale;
}
2025-09-30 15:06:02 +02:00
export class LocaleNotLoadedError extends Error {
constructor(locale: Locale) {
super(`Locale ${locale} is not loaded in emoji database`);
this.name = 'LocaleNotLoadedError';
}
}
2025-07-31 19:30:14 +02:00
async function hasLocale(locale: Locale, db: Database): Promise<boolean> {
if (loadedLocales.has(locale)) {
return true;
}
const rowCount = await db.count(locale);
return !!rowCount;
}
// Testing helpers
export async function testGet() {
const db = await loadDB();
return { db, loadedLocales };
}
export function testClear() {
loadedLocales.clear();
loadDB.reset();
}