1080 lines
29 KiB
JavaScript
1080 lines
29 KiB
JavaScript
function assertNonEmptyString(str) {
|
||
if (typeof str !== "string" || !str) {
|
||
throw new Error("expected a non-empty string, got: " + str);
|
||
}
|
||
}
|
||
|
||
function assertNumber(number) {
|
||
if (typeof number !== "number") {
|
||
throw new Error("expected a number, got: " + number);
|
||
}
|
||
}
|
||
|
||
const DB_VERSION_CURRENT = 1;
|
||
const DB_VERSION_INITIAL = 1;
|
||
const STORE_EMOJI = "emoji";
|
||
const STORE_KEYVALUE = "keyvalue";
|
||
const STORE_FAVORITES = "favorites";
|
||
const FIELD_TOKENS = "tokens";
|
||
const INDEX_TOKENS = "tokens";
|
||
const FIELD_UNICODE = "unicode";
|
||
const INDEX_COUNT = "count";
|
||
const FIELD_GROUP = "group";
|
||
const FIELD_ORDER = "order";
|
||
const INDEX_GROUP_AND_ORDER = "group-order";
|
||
const KEY_ETAG = "eTag";
|
||
const KEY_URL = "url";
|
||
const KEY_PREFERRED_SKINTONE = "skinTone";
|
||
const MODE_READONLY = "readonly";
|
||
const MODE_READWRITE = "readwrite";
|
||
const INDEX_SKIN_UNICODE = "skinUnicodes";
|
||
const FIELD_SKIN_UNICODE = "skinUnicodes";
|
||
|
||
const DEFAULT_DATA_SOURCE =
|
||
"https://cdn.jsdelivr.net/npm/emoji-picker-element-data@^1/en/emojibase/data.json";
|
||
const DEFAULT_LOCALE = "en";
|
||
|
||
// like lodash's uniqBy but much smaller
|
||
function uniqBy(arr, func) {
|
||
const set = new Set();
|
||
const res = [];
|
||
for (const item of arr) {
|
||
const key = func(item);
|
||
if (!set.has(key)) {
|
||
set.add(key);
|
||
res.push(item);
|
||
}
|
||
}
|
||
return res;
|
||
}
|
||
|
||
function uniqEmoji(emojis) {
|
||
return uniqBy(emojis, (_) => _.unicode);
|
||
}
|
||
|
||
function initialMigration(db) {
|
||
function createObjectStore(name, keyPath, indexes) {
|
||
const store = keyPath
|
||
? db.createObjectStore(name, { keyPath })
|
||
: db.createObjectStore(name);
|
||
if (indexes) {
|
||
for (const [indexName, [keyPath, multiEntry]] of Object.entries(
|
||
indexes
|
||
)) {
|
||
store.createIndex(indexName, keyPath, { multiEntry });
|
||
}
|
||
}
|
||
return store;
|
||
}
|
||
|
||
createObjectStore(STORE_KEYVALUE);
|
||
createObjectStore(STORE_EMOJI, /* keyPath */ FIELD_UNICODE, {
|
||
[INDEX_TOKENS]: [FIELD_TOKENS, /* multiEntry */ true],
|
||
[INDEX_GROUP_AND_ORDER]: [[FIELD_GROUP, FIELD_ORDER]],
|
||
[INDEX_SKIN_UNICODE]: [FIELD_SKIN_UNICODE, /* multiEntry */ true],
|
||
});
|
||
createObjectStore(STORE_FAVORITES, undefined, {
|
||
[INDEX_COUNT]: [""],
|
||
});
|
||
}
|
||
|
||
const openIndexedDBRequests = {};
|
||
const databaseCache = {};
|
||
const onCloseListeners = {};
|
||
|
||
function handleOpenOrDeleteReq(resolve, reject, req) {
|
||
// These things are almost impossible to test with fakeIndexedDB sadly
|
||
/* istanbul ignore next */
|
||
req.onerror = () => reject(req.error);
|
||
/* istanbul ignore next */
|
||
req.onblocked = () => reject(new Error("IDB blocked"));
|
||
req.onsuccess = () => resolve(req.result);
|
||
}
|
||
|
||
async function createDatabase(dbName) {
|
||
const db = await new Promise((resolve, reject) => {
|
||
const req = indexedDB.open(dbName, DB_VERSION_CURRENT);
|
||
openIndexedDBRequests[dbName] = req;
|
||
req.onupgradeneeded = (e) => {
|
||
// Technically there is only one version, so we don't need this `if` check
|
||
// But if an old version of the JS is in another browser tab
|
||
// and it gets upgraded in the future and we have a new DB version, well...
|
||
// better safe than sorry.
|
||
/* istanbul ignore else */
|
||
if (e.oldVersion < DB_VERSION_INITIAL) {
|
||
initialMigration(req.result);
|
||
}
|
||
};
|
||
handleOpenOrDeleteReq(resolve, reject, req);
|
||
});
|
||
// Handle abnormal closes, e.g. "delete database" in chrome dev tools.
|
||
// No need for removeEventListener, because once the DB can no longer
|
||
// fire "close" events, it will auto-GC.
|
||
// Unfortunately cannot test in fakeIndexedDB: https://github.com/dumbmatter/fakeIndexedDB/issues/50
|
||
/* istanbul ignore next */
|
||
db.onclose = () => closeDatabase(dbName);
|
||
return db;
|
||
}
|
||
|
||
function openDatabase(dbName) {
|
||
if (!databaseCache[dbName]) {
|
||
databaseCache[dbName] = createDatabase(dbName);
|
||
}
|
||
return databaseCache[dbName];
|
||
}
|
||
|
||
function dbPromise(db, storeName, readOnlyOrReadWrite, cb) {
|
||
return new Promise((resolve, reject) => {
|
||
// Use relaxed durability because neither the emoji data nor the favorites/preferred skin tone
|
||
// are really irreplaceable data. IndexedDB is just a cache in this case.
|
||
const txn = db.transaction(storeName, readOnlyOrReadWrite, {
|
||
durability: "relaxed",
|
||
});
|
||
const store =
|
||
typeof storeName === "string"
|
||
? txn.objectStore(storeName)
|
||
: storeName.map((name) => txn.objectStore(name));
|
||
let res;
|
||
cb(store, txn, (result) => {
|
||
res = result;
|
||
});
|
||
|
||
txn.oncomplete = () => resolve(res);
|
||
/* istanbul ignore next */
|
||
txn.onerror = () => reject(txn.error);
|
||
});
|
||
}
|
||
|
||
function closeDatabase(dbName) {
|
||
// close any open requests
|
||
const req = openIndexedDBRequests[dbName];
|
||
const db = req && req.result;
|
||
if (db) {
|
||
db.close();
|
||
const listeners = onCloseListeners[dbName];
|
||
/* istanbul ignore else */
|
||
if (listeners) {
|
||
for (const listener of listeners) {
|
||
listener();
|
||
}
|
||
}
|
||
}
|
||
delete openIndexedDBRequests[dbName];
|
||
delete databaseCache[dbName];
|
||
delete onCloseListeners[dbName];
|
||
}
|
||
|
||
function deleteDatabase(dbName) {
|
||
return new Promise((resolve, reject) => {
|
||
// close any open requests
|
||
closeDatabase(dbName);
|
||
const req = indexedDB.deleteDatabase(dbName);
|
||
handleOpenOrDeleteReq(resolve, reject, req);
|
||
});
|
||
}
|
||
|
||
// The "close" event occurs during an abnormal shutdown, e.g. a user clearing their browser data.
|
||
// However, it doesn't occur with the normal "close" event, so we handle that separately.
|
||
// https://www.w3.org/TR/IndexedDB/#close-a-database-connection
|
||
function addOnCloseListener(dbName, listener) {
|
||
let listeners = onCloseListeners[dbName];
|
||
if (!listeners) {
|
||
listeners = onCloseListeners[dbName] = [];
|
||
}
|
||
listeners.push(listener);
|
||
}
|
||
|
||
// list of emoticons that don't match a simple \W+ regex
|
||
// extracted using:
|
||
// require('emoji-picker-element-data/en/emojibase/data.json').map(_ => _.emoticon).filter(Boolean).filter(_ => !/^\W+$/.test(_))
|
||
const irregularEmoticons = new Set([
|
||
":D",
|
||
"XD",
|
||
":'D",
|
||
"O:)",
|
||
":X",
|
||
":P",
|
||
";P",
|
||
"XP",
|
||
":L",
|
||
":Z",
|
||
":j",
|
||
"8D",
|
||
"XO",
|
||
"8)",
|
||
":B",
|
||
":O",
|
||
":S",
|
||
":'o",
|
||
"Dx",
|
||
"X(",
|
||
"D:",
|
||
":C",
|
||
">0)",
|
||
":3",
|
||
"</3",
|
||
"<3",
|
||
"\\M/",
|
||
":E",
|
||
"8#",
|
||
]);
|
||
|
||
function extractTokens(str) {
|
||
return str
|
||
.split(/[\s_]+/)
|
||
.map((word) => {
|
||
if (!word.match(/\w/) || irregularEmoticons.has(word)) {
|
||
// for pure emoticons like :) or :-), just leave them as-is
|
||
return word.toLowerCase();
|
||
}
|
||
|
||
return word
|
||
.replace(/[)(:,]/g, "")
|
||
.replace(/’/g, "'")
|
||
.toLowerCase();
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
const MIN_SEARCH_TEXT_LENGTH = 2;
|
||
|
||
// This is an extra step in addition to extractTokens(). The difference here is that we expect
|
||
// the input to have already been run through extractTokens(). This is useful for cases like
|
||
// emoticons, where we don't want to do any tokenization (because it makes no sense to split up
|
||
// ">:)" by the colon) but we do want to lowercase it to have consistent search results, so that
|
||
// the user can type ':P' or ':p' and still get the same result.
|
||
function normalizeTokens(str) {
|
||
return str
|
||
.filter(Boolean)
|
||
.map((_) => _.toLowerCase())
|
||
.filter((_) => _.length >= MIN_SEARCH_TEXT_LENGTH);
|
||
}
|
||
|
||
// Transform emoji data for storage in IDB
|
||
function transformEmojiData(emojiData) {
|
||
const res = emojiData.map(
|
||
({
|
||
annotation,
|
||
emoticon,
|
||
group,
|
||
order,
|
||
shortcodes,
|
||
skins,
|
||
tags,
|
||
emoji,
|
||
version,
|
||
}) => {
|
||
const tokens = [
|
||
...new Set(
|
||
normalizeTokens([
|
||
...(shortcodes || []).map(extractTokens).flat(),
|
||
...tags.map(extractTokens).flat(),
|
||
...extractTokens(annotation),
|
||
emoticon,
|
||
])
|
||
),
|
||
].sort();
|
||
const res = {
|
||
annotation,
|
||
group,
|
||
order,
|
||
tags,
|
||
tokens,
|
||
unicode: emoji,
|
||
version,
|
||
};
|
||
if (emoticon) {
|
||
res.emoticon = emoticon;
|
||
}
|
||
if (shortcodes) {
|
||
res.shortcodes = shortcodes;
|
||
}
|
||
if (skins) {
|
||
res.skinTones = [];
|
||
res.skinUnicodes = [];
|
||
res.skinVersions = [];
|
||
for (const { tone, emoji, version } of skins) {
|
||
res.skinTones.push(tone);
|
||
res.skinUnicodes.push(emoji);
|
||
res.skinVersions.push(version);
|
||
}
|
||
}
|
||
return res;
|
||
}
|
||
);
|
||
return res;
|
||
}
|
||
|
||
// helper functions that help compress the code better
|
||
|
||
function callStore(store, method, key, cb) {
|
||
store[method](key).onsuccess = (e) => cb && cb(e.target.result);
|
||
}
|
||
|
||
function getIDB(store, key, cb) {
|
||
callStore(store, "get", key, cb);
|
||
}
|
||
|
||
function getAllIDB(store, key, cb) {
|
||
callStore(store, "getAll", key, cb);
|
||
}
|
||
|
||
function commit(txn) {
|
||
/* istanbul ignore else */
|
||
if (txn.commit) {
|
||
txn.commit();
|
||
}
|
||
}
|
||
|
||
// like lodash's minBy
|
||
function minBy(array, func) {
|
||
let minItem = array[0];
|
||
for (let i = 1; i < array.length; i++) {
|
||
const item = array[i];
|
||
if (func(minItem) > func(item)) {
|
||
minItem = item;
|
||
}
|
||
}
|
||
return minItem;
|
||
}
|
||
|
||
// return an array of results representing all items that are found in each one of the arrays
|
||
//
|
||
|
||
function findCommonMembers(arrays, uniqByFunc) {
|
||
const shortestArray = minBy(arrays, (_) => _.length);
|
||
const results = [];
|
||
for (const item of shortestArray) {
|
||
// if this item is included in every array in the intermediate results, add it to the final results
|
||
if (
|
||
!arrays.some(
|
||
(array) =>
|
||
array.findIndex((_) => uniqByFunc(_) === uniqByFunc(item)) === -1
|
||
)
|
||
) {
|
||
results.push(item);
|
||
}
|
||
}
|
||
return results;
|
||
}
|
||
|
||
async function isEmpty(db) {
|
||
return !(await get(db, STORE_KEYVALUE, KEY_URL));
|
||
}
|
||
|
||
async function hasData(db, url, eTag) {
|
||
const [oldETag, oldUrl] = await Promise.all(
|
||
[KEY_ETAG, KEY_URL].map((key) => get(db, STORE_KEYVALUE, key))
|
||
);
|
||
return oldETag === eTag && oldUrl === url;
|
||
}
|
||
|
||
async function doFullDatabaseScanForSingleResult(db, predicate) {
|
||
// This batching algorithm is just a perf improvement over a basic
|
||
// cursor. The BATCH_SIZE is an estimate of what would give the best
|
||
// perf for doing a full DB scan (worst case).
|
||
//
|
||
// Mini-benchmark for determining the best batch size:
|
||
//
|
||
// PERF=1 pnpm build:rollup && pnpm test:adhoc
|
||
//
|
||
// (async () => {
|
||
// performance.mark('start')
|
||
// await $('emoji-picker').database.getEmojiByShortcode('doesnotexist')
|
||
// performance.measure('total', 'start')
|
||
// console.log(performance.getEntriesByName('total').slice(-1)[0].duration)
|
||
// })()
|
||
const BATCH_SIZE = 50; // Typically around 150ms for 6x slowdown in Chrome for above benchmark
|
||
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => {
|
||
let lastKey;
|
||
|
||
const processNextBatch = () => {
|
||
emojiStore.getAll(
|
||
lastKey && IDBKeyRange.lowerBound(lastKey, true),
|
||
BATCH_SIZE
|
||
).onsuccess = (e) => {
|
||
const results = e.target.result;
|
||
for (const result of results) {
|
||
lastKey = result.unicode;
|
||
if (predicate(result)) {
|
||
return cb(result);
|
||
}
|
||
}
|
||
if (results.length < BATCH_SIZE) {
|
||
return cb();
|
||
}
|
||
processNextBatch();
|
||
};
|
||
};
|
||
processNextBatch();
|
||
});
|
||
}
|
||
|
||
async function loadData(db, emojiData, url, eTag) {
|
||
try {
|
||
const transformedData = transformEmojiData(emojiData);
|
||
await dbPromise(
|
||
db,
|
||
[STORE_EMOJI, STORE_KEYVALUE],
|
||
MODE_READWRITE,
|
||
([emojiStore, metaStore], txn) => {
|
||
let oldETag;
|
||
let oldUrl;
|
||
let todo = 0;
|
||
|
||
function checkFetched() {
|
||
if (++todo === 2) {
|
||
// 2 requests made
|
||
onFetched();
|
||
}
|
||
}
|
||
|
||
function onFetched() {
|
||
if (oldETag === eTag && oldUrl === url) {
|
||
// check again within the transaction to guard against concurrency, e.g. multiple browser tabs
|
||
return;
|
||
}
|
||
// delete old data
|
||
emojiStore.clear();
|
||
// insert new data
|
||
for (const data of transformedData) {
|
||
emojiStore.put(data);
|
||
}
|
||
metaStore.put(eTag, KEY_ETAG);
|
||
metaStore.put(url, KEY_URL);
|
||
commit(txn);
|
||
}
|
||
|
||
getIDB(metaStore, KEY_ETAG, (result) => {
|
||
oldETag = result;
|
||
checkFetched();
|
||
});
|
||
|
||
getIDB(metaStore, KEY_URL, (result) => {
|
||
oldUrl = result;
|
||
checkFetched();
|
||
});
|
||
}
|
||
);
|
||
} finally {
|
||
}
|
||
}
|
||
|
||
async function getEmojiByGroup(db, group) {
|
||
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => {
|
||
const range = IDBKeyRange.bound([group, 0], [group + 1, 0], false, true);
|
||
getAllIDB(emojiStore.index(INDEX_GROUP_AND_ORDER), range, cb);
|
||
});
|
||
}
|
||
|
||
async function getEmojiBySearchQuery(db, query) {
|
||
const tokens = normalizeTokens(extractTokens(query));
|
||
|
||
if (!tokens.length) {
|
||
return [];
|
||
}
|
||
|
||
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => {
|
||
// get all results that contain all tokens (i.e. an AND query)
|
||
const intermediateResults = [];
|
||
|
||
const checkDone = () => {
|
||
if (intermediateResults.length === tokens.length) {
|
||
onDone();
|
||
}
|
||
};
|
||
|
||
const onDone = () => {
|
||
const results = findCommonMembers(intermediateResults, (_) => _.unicode);
|
||
cb(results.sort((a, b) => (a.order < b.order ? -1 : 1)));
|
||
};
|
||
|
||
for (let i = 0; i < tokens.length; i++) {
|
||
const token = tokens[i];
|
||
const range =
|
||
i === tokens.length - 1
|
||
? IDBKeyRange.bound(token, token + "\uffff", false, true) // treat last token as a prefix search
|
||
: IDBKeyRange.only(token); // treat all other tokens as an exact match
|
||
getAllIDB(emojiStore.index(INDEX_TOKENS), range, (result) => {
|
||
intermediateResults.push(result);
|
||
checkDone();
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// This could have been implemented as an IDB index on shortcodes, but it seemed wasteful to do that
|
||
// when we can already query by tokens and this will give us what we're looking for 99.9% of the time
|
||
async function getEmojiByShortcode(db, shortcode) {
|
||
const emojis = await getEmojiBySearchQuery(db, shortcode);
|
||
|
||
// In very rare cases (e.g. the shortcode "v" as in "v for victory"), we cannot search because
|
||
// there are no usable tokens (too short in this case). In that case, we have to do an inefficient
|
||
// full-database scan, which I believe is an acceptable tradeoff for not having to have an extra
|
||
// index on shortcodes.
|
||
|
||
if (!emojis.length) {
|
||
const predicate = (_) =>
|
||
(_.shortcodes || []).includes(shortcode.toLowerCase());
|
||
return (await doFullDatabaseScanForSingleResult(db, predicate)) || null;
|
||
}
|
||
|
||
return (
|
||
emojis.filter((_) => {
|
||
const lowerShortcodes = (_.shortcodes || []).map((_) => _.toLowerCase());
|
||
return lowerShortcodes.includes(shortcode.toLowerCase());
|
||
})[0] || null
|
||
);
|
||
}
|
||
|
||
async function getEmojiByUnicode(db, unicode) {
|
||
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) =>
|
||
getIDB(emojiStore, unicode, (result) => {
|
||
if (result) {
|
||
return cb(result);
|
||
}
|
||
getIDB(emojiStore.index(INDEX_SKIN_UNICODE), unicode, (result) =>
|
||
cb(result || null)
|
||
);
|
||
})
|
||
);
|
||
}
|
||
|
||
function get(db, storeName, key) {
|
||
return dbPromise(db, storeName, MODE_READONLY, (store, txn, cb) =>
|
||
getIDB(store, key, cb)
|
||
);
|
||
}
|
||
|
||
function set(db, storeName, key, value) {
|
||
return dbPromise(db, storeName, MODE_READWRITE, (store, txn) => {
|
||
store.put(value, key);
|
||
commit(txn);
|
||
});
|
||
}
|
||
|
||
function incrementFavoriteEmojiCount(db, unicode) {
|
||
return dbPromise(db, STORE_FAVORITES, MODE_READWRITE, (store, txn) =>
|
||
getIDB(store, unicode, (result) => {
|
||
store.put((result || 0) + 1, unicode);
|
||
commit(txn);
|
||
})
|
||
);
|
||
}
|
||
|
||
function getTopFavoriteEmoji(db, customEmojiIndex, limit) {
|
||
if (limit === 0) {
|
||
return [];
|
||
}
|
||
return dbPromise(
|
||
db,
|
||
[STORE_FAVORITES, STORE_EMOJI],
|
||
MODE_READONLY,
|
||
([favoritesStore, emojiStore], txn, cb) => {
|
||
const results = [];
|
||
favoritesStore
|
||
.index(INDEX_COUNT)
|
||
.openCursor(undefined, "prev").onsuccess = (e) => {
|
||
const cursor = e.target.result;
|
||
if (!cursor) {
|
||
// no more results
|
||
return cb(results);
|
||
}
|
||
|
||
function addResult(result) {
|
||
results.push(result);
|
||
if (results.length === limit) {
|
||
return cb(results); // done, reached the limit
|
||
}
|
||
cursor.continue();
|
||
}
|
||
|
||
const unicodeOrName = cursor.primaryKey;
|
||
const custom = customEmojiIndex.byName(unicodeOrName);
|
||
if (custom) {
|
||
return addResult(custom);
|
||
}
|
||
// This could be done in parallel (i.e. make the cursor and the get()s parallelized),
|
||
// but my testing suggests it's not actually faster.
|
||
getIDB(emojiStore, unicodeOrName, (emoji) => {
|
||
if (emoji) {
|
||
return addResult(emoji);
|
||
}
|
||
// emoji not found somehow, ignore (may happen if custom emoji change)
|
||
cursor.continue();
|
||
});
|
||
};
|
||
}
|
||
);
|
||
}
|
||
|
||
// trie data structure for prefix searches
|
||
// loosely based on https://github.com/nolanlawson/substring-trie
|
||
|
||
const CODA_MARKER = ""; // marks the end of the string
|
||
|
||
function trie(arr, itemToTokens) {
|
||
const map = new Map();
|
||
for (const item of arr) {
|
||
const tokens = itemToTokens(item);
|
||
for (const token of tokens) {
|
||
let currentMap = map;
|
||
for (let i = 0; i < token.length; i++) {
|
||
const char = token.charAt(i);
|
||
let nextMap = currentMap.get(char);
|
||
if (!nextMap) {
|
||
nextMap = new Map();
|
||
currentMap.set(char, nextMap);
|
||
}
|
||
currentMap = nextMap;
|
||
}
|
||
let valuesAtCoda = currentMap.get(CODA_MARKER);
|
||
if (!valuesAtCoda) {
|
||
valuesAtCoda = [];
|
||
currentMap.set(CODA_MARKER, valuesAtCoda);
|
||
}
|
||
valuesAtCoda.push(item);
|
||
}
|
||
}
|
||
|
||
const search = (query, exact) => {
|
||
let currentMap = map;
|
||
for (let i = 0; i < query.length; i++) {
|
||
const char = query.charAt(i);
|
||
const nextMap = currentMap.get(char);
|
||
if (nextMap) {
|
||
currentMap = nextMap;
|
||
} else {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
if (exact) {
|
||
const results = currentMap.get(CODA_MARKER);
|
||
return results || [];
|
||
}
|
||
|
||
const results = [];
|
||
// traverse
|
||
const queue = [currentMap];
|
||
while (queue.length) {
|
||
const currentMap = queue.shift();
|
||
const entriesSortedByKey = [...currentMap.entries()].sort((a, b) =>
|
||
a[0] < b[0] ? -1 : 1
|
||
);
|
||
for (const [key, value] of entriesSortedByKey) {
|
||
if (key === CODA_MARKER) {
|
||
// CODA_MARKER always comes first; it's the empty string
|
||
results.push(...value);
|
||
} else {
|
||
queue.push(value);
|
||
}
|
||
}
|
||
}
|
||
return results;
|
||
};
|
||
|
||
return search;
|
||
}
|
||
|
||
const requiredKeys$1 = ["name", "url"];
|
||
|
||
function assertCustomEmojis(customEmojis) {
|
||
const isArray = customEmojis && Array.isArray(customEmojis);
|
||
const firstItemIsFaulty =
|
||
isArray &&
|
||
customEmojis.length &&
|
||
(!customEmojis[0] ||
|
||
requiredKeys$1.some((key) => !(key in customEmojis[0])));
|
||
if (!isArray || firstItemIsFaulty) {
|
||
throw new Error("Custom emojis are in the wrong format");
|
||
}
|
||
}
|
||
|
||
function customEmojiIndex(customEmojis) {
|
||
assertCustomEmojis(customEmojis);
|
||
|
||
const sortByName = (a, b) =>
|
||
a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
|
||
|
||
//
|
||
// all()
|
||
//
|
||
const all = customEmojis.sort(sortByName);
|
||
|
||
//
|
||
// search()
|
||
//
|
||
const emojiToTokens = (emoji) => [
|
||
...new Set(
|
||
(emoji.shortcodes || [])
|
||
.map((shortcode) => extractTokens(shortcode))
|
||
.flat()
|
||
),
|
||
];
|
||
const searchTrie = trie(customEmojis, emojiToTokens);
|
||
const searchByExactMatch = (_) => searchTrie(_, true);
|
||
const searchByPrefix = (_) => searchTrie(_, false);
|
||
|
||
// Search by query for custom emoji. Similar to how we do this in IDB, the last token
|
||
// is treated as a prefix search, but every other one is treated as an exact match.
|
||
// Then we AND the results together
|
||
const search = (query) => {
|
||
const tokens = extractTokens(query);
|
||
const intermediateResults = tokens.map((token, i) =>
|
||
(i < tokens.length - 1 ? searchByExactMatch : searchByPrefix)(token)
|
||
);
|
||
return findCommonMembers(intermediateResults, (_) => _.name).sort(
|
||
sortByName
|
||
);
|
||
};
|
||
|
||
//
|
||
// byShortcode, byName
|
||
//
|
||
const shortcodeToEmoji = new Map();
|
||
const nameToEmoji = new Map();
|
||
for (const customEmoji of customEmojis) {
|
||
nameToEmoji.set(customEmoji.name.toLowerCase(), customEmoji);
|
||
for (const shortcode of customEmoji.shortcodes || []) {
|
||
shortcodeToEmoji.set(shortcode.toLowerCase(), customEmoji);
|
||
}
|
||
}
|
||
|
||
const byShortcode = (shortcode) =>
|
||
shortcodeToEmoji.get(shortcode.toLowerCase());
|
||
const byName = (name) => nameToEmoji.get(name.toLowerCase());
|
||
|
||
return {
|
||
all,
|
||
search,
|
||
byShortcode,
|
||
byName,
|
||
};
|
||
}
|
||
|
||
const isFirefoxContentScript = typeof wrappedJSObject !== "undefined";
|
||
|
||
// remove some internal implementation details, i.e. the "tokens" array on the emoji object
|
||
// essentially, convert the emoji from the version stored in IDB to the version used in-memory
|
||
function cleanEmoji(emoji) {
|
||
if (!emoji) {
|
||
return emoji;
|
||
}
|
||
// if inside a Firefox content script, need to clone the emoji object to prevent Firefox from complaining about
|
||
// cross-origin object. See: https://github.com/nolanlawson/emoji-picker-element/issues/356
|
||
/* istanbul ignore if */
|
||
if (isFirefoxContentScript) {
|
||
emoji = structuredClone(emoji);
|
||
}
|
||
delete emoji.tokens;
|
||
if (emoji.skinTones) {
|
||
const len = emoji.skinTones.length;
|
||
emoji.skins = Array(len);
|
||
for (let i = 0; i < len; i++) {
|
||
emoji.skins[i] = {
|
||
tone: emoji.skinTones[i],
|
||
unicode: emoji.skinUnicodes[i],
|
||
version: emoji.skinVersions[i],
|
||
};
|
||
}
|
||
delete emoji.skinTones;
|
||
delete emoji.skinUnicodes;
|
||
delete emoji.skinVersions;
|
||
}
|
||
return emoji;
|
||
}
|
||
|
||
function warnETag(eTag) {
|
||
if (!eTag) {
|
||
console.warn(
|
||
"emoji-picker-element is more efficient if the dataSource server exposes an ETag header."
|
||
);
|
||
}
|
||
}
|
||
|
||
const requiredKeys = [
|
||
"annotation",
|
||
"emoji",
|
||
"group",
|
||
"order",
|
||
"tags",
|
||
"version",
|
||
];
|
||
|
||
function assertEmojiData(emojiData) {
|
||
if (
|
||
!emojiData ||
|
||
!Array.isArray(emojiData) ||
|
||
!emojiData[0] ||
|
||
typeof emojiData[0] !== "object" ||
|
||
requiredKeys.some((key) => !(key in emojiData[0]))
|
||
) {
|
||
throw new Error("Emoji data is in the wrong format");
|
||
}
|
||
}
|
||
|
||
function assertStatus(response, dataSource) {
|
||
if (Math.floor(response.status / 100) !== 2) {
|
||
throw new Error("Failed to fetch: " + dataSource + ": " + response.status);
|
||
}
|
||
}
|
||
|
||
async function getETag(dataSource) {
|
||
const response = await fetch(dataSource, { method: "HEAD" });
|
||
assertStatus(response, dataSource);
|
||
const eTag = response.headers.get("etag");
|
||
warnETag(eTag);
|
||
return eTag;
|
||
}
|
||
|
||
async function getETagAndData(dataSource) {
|
||
const response = await fetch(dataSource);
|
||
assertStatus(response, dataSource);
|
||
const eTag = response.headers.get("etag");
|
||
warnETag(eTag);
|
||
const emojiData = await response.json();
|
||
assertEmojiData(emojiData);
|
||
return [eTag, emojiData];
|
||
}
|
||
|
||
// TODO: including these in blob-util.ts causes typedoc to generate docs for them,
|
||
// even with --excludePrivate ¯\_(ツ)_/¯
|
||
/** @private */
|
||
/**
|
||
* Convert an `ArrayBuffer` to a binary string.
|
||
*
|
||
* Example:
|
||
*
|
||
* ```js
|
||
* var myString = blobUtil.arrayBufferToBinaryString(arrayBuff)
|
||
* ```
|
||
*
|
||
* @param buffer - array buffer
|
||
* @returns binary string
|
||
*/
|
||
function arrayBufferToBinaryString(buffer) {
|
||
var binary = "";
|
||
var bytes = new Uint8Array(buffer);
|
||
var length = bytes.byteLength;
|
||
var i = -1;
|
||
while (++i < length) {
|
||
binary += String.fromCharCode(bytes[i]);
|
||
}
|
||
return binary;
|
||
}
|
||
/**
|
||
* Convert a binary string to an `ArrayBuffer`.
|
||
*
|
||
* ```js
|
||
* var myBuffer = blobUtil.binaryStringToArrayBuffer(binaryString)
|
||
* ```
|
||
*
|
||
* @param binary - binary string
|
||
* @returns array buffer
|
||
*/
|
||
function binaryStringToArrayBuffer(binary) {
|
||
var length = binary.length;
|
||
var buf = new ArrayBuffer(length);
|
||
var arr = new Uint8Array(buf);
|
||
var i = -1;
|
||
while (++i < length) {
|
||
arr[i] = binary.charCodeAt(i);
|
||
}
|
||
return buf;
|
||
}
|
||
|
||
// generate a checksum based on the stringified JSON
|
||
async function jsonChecksum(object) {
|
||
const inString = JSON.stringify(object);
|
||
let inBuffer = binaryStringToArrayBuffer(inString);
|
||
|
||
// this does not need to be cryptographically secure, SHA-1 is fine
|
||
const outBuffer = await crypto.subtle.digest("SHA-1", inBuffer);
|
||
const outBinString = arrayBufferToBinaryString(outBuffer);
|
||
const res = btoa(outBinString);
|
||
return res;
|
||
}
|
||
|
||
async function checkForUpdates(db, dataSource) {
|
||
// just do a simple HEAD request first to see if the eTags match
|
||
let emojiData;
|
||
let eTag = await getETag(dataSource);
|
||
if (!eTag) {
|
||
// work around lack of ETag/Access-Control-Expose-Headers
|
||
const eTagAndData = await getETagAndData(dataSource);
|
||
eTag = eTagAndData[0];
|
||
emojiData = eTagAndData[1];
|
||
if (!eTag) {
|
||
eTag = await jsonChecksum(emojiData);
|
||
}
|
||
}
|
||
if (await hasData(db, dataSource, eTag));
|
||
else {
|
||
if (!emojiData) {
|
||
const eTagAndData = await getETagAndData(dataSource);
|
||
emojiData = eTagAndData[1];
|
||
}
|
||
await loadData(db, emojiData, dataSource, eTag);
|
||
}
|
||
}
|
||
|
||
async function loadDataForFirstTime(db, dataSource) {
|
||
let [eTag, emojiData] = await getETagAndData(dataSource);
|
||
if (!eTag) {
|
||
// Handle lack of support for ETag or Access-Control-Expose-Headers
|
||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers#Browser_compatibility
|
||
eTag = await jsonChecksum(emojiData);
|
||
}
|
||
|
||
await loadData(db, emojiData, dataSource, eTag);
|
||
}
|
||
|
||
class Database {
|
||
constructor({
|
||
dataSource = DEFAULT_DATA_SOURCE,
|
||
locale = DEFAULT_LOCALE,
|
||
customEmoji = [],
|
||
} = {}) {
|
||
this.dataSource = dataSource;
|
||
this.locale = locale;
|
||
this._dbName = `emoji-picker-element-${this.locale}`;
|
||
this._db = undefined;
|
||
this._lazyUpdate = undefined;
|
||
this._custom = customEmojiIndex(customEmoji);
|
||
|
||
this._clear = this._clear.bind(this);
|
||
this._ready = this._init();
|
||
}
|
||
|
||
async _init() {
|
||
const db = (this._db = await openDatabase(this._dbName));
|
||
|
||
addOnCloseListener(this._dbName, this._clear);
|
||
const dataSource = this.dataSource;
|
||
const empty = await isEmpty(db);
|
||
|
||
if (empty) {
|
||
await loadDataForFirstTime(db, dataSource);
|
||
} else {
|
||
// offline-first - do an update asynchronously
|
||
this._lazyUpdate = checkForUpdates(db, dataSource);
|
||
}
|
||
}
|
||
|
||
async ready() {
|
||
const checkReady = async () => {
|
||
if (!this._ready) {
|
||
this._ready = this._init();
|
||
}
|
||
return this._ready;
|
||
};
|
||
await checkReady();
|
||
// There's a possibility of a race condition where the element gets added, removed, and then added again
|
||
// with a particular timing, which would set the _db to undefined.
|
||
// We *could* do a while loop here, but that seems excessive and could lead to an infinite loop.
|
||
if (!this._db) {
|
||
await checkReady();
|
||
}
|
||
}
|
||
|
||
async getEmojiByGroup(group) {
|
||
assertNumber(group);
|
||
await this.ready();
|
||
return uniqEmoji(await getEmojiByGroup(this._db, group)).map(cleanEmoji);
|
||
}
|
||
|
||
async getEmojiBySearchQuery(query) {
|
||
assertNonEmptyString(query);
|
||
await this.ready();
|
||
const customs = this._custom.search(query);
|
||
const natives = uniqEmoji(await getEmojiBySearchQuery(this._db, query)).map(
|
||
cleanEmoji
|
||
);
|
||
return [...customs, ...natives];
|
||
}
|
||
|
||
async getEmojiByShortcode(shortcode) {
|
||
assertNonEmptyString(shortcode);
|
||
await this.ready();
|
||
const custom = this._custom.byShortcode(shortcode);
|
||
if (custom) {
|
||
return custom;
|
||
}
|
||
return cleanEmoji(await getEmojiByShortcode(this._db, shortcode));
|
||
}
|
||
|
||
async getEmojiByUnicodeOrName(unicodeOrName) {
|
||
assertNonEmptyString(unicodeOrName);
|
||
await this.ready();
|
||
const custom = this._custom.byName(unicodeOrName);
|
||
if (custom) {
|
||
return custom;
|
||
}
|
||
return cleanEmoji(await getEmojiByUnicode(this._db, unicodeOrName));
|
||
}
|
||
|
||
async getPreferredSkinTone() {
|
||
await this.ready();
|
||
return (await get(this._db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE)) || 0;
|
||
}
|
||
|
||
async setPreferredSkinTone(skinTone) {
|
||
assertNumber(skinTone);
|
||
await this.ready();
|
||
return set(this._db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE, skinTone);
|
||
}
|
||
|
||
async incrementFavoriteEmojiCount(unicodeOrName) {
|
||
assertNonEmptyString(unicodeOrName);
|
||
await this.ready();
|
||
return incrementFavoriteEmojiCount(this._db, unicodeOrName);
|
||
}
|
||
|
||
async getTopFavoriteEmoji(limit) {
|
||
assertNumber(limit);
|
||
await this.ready();
|
||
return (await getTopFavoriteEmoji(this._db, this._custom, limit)).map(
|
||
cleanEmoji
|
||
);
|
||
}
|
||
|
||
set customEmoji(customEmojis) {
|
||
this._custom = customEmojiIndex(customEmojis);
|
||
}
|
||
|
||
get customEmoji() {
|
||
return this._custom.all;
|
||
}
|
||
|
||
async _shutdown() {
|
||
await this.ready(); // reopen if we've already been closed/deleted
|
||
try {
|
||
await this._lazyUpdate; // allow any lazy updates to process before closing/deleting
|
||
} catch (err) {
|
||
/* ignore network errors (offline-first) */
|
||
}
|
||
}
|
||
|
||
// clear references to IDB, e.g. during a close event
|
||
_clear() {
|
||
// We don't need to call removeEventListener or remove the manual "close" listeners.
|
||
// The memory leak tests prove this is unnecessary. It's because:
|
||
// 1) IDBDatabases that can no longer fire "close" automatically have listeners GCed
|
||
// 2) we clear the manual close listeners in databaseLifecycle.js.
|
||
this._db = this._ready = this._lazyUpdate = undefined;
|
||
}
|
||
|
||
async close() {
|
||
await this._shutdown();
|
||
await closeDatabase(this._dbName);
|
||
}
|
||
|
||
async delete() {
|
||
await this._shutdown();
|
||
await deleteDatabase(this._dbName);
|
||
}
|
||
}
|
||
|
||
export { Database as default };
|