mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-27 13:47:58 +01:00
135 lines
3.5 KiB
JavaScript
135 lines
3.5 KiB
JavaScript
|
//@ts-check
|
||
|
/*
|
||
|
* Client-side tag completion.
|
||
|
*/
|
||
|
|
||
|
/**
|
||
|
* @typedef {object} Result
|
||
|
* @property {string} name
|
||
|
* @property {number} imageCount
|
||
|
* @property {number[]} associations
|
||
|
*/
|
||
|
|
||
|
/**
|
||
|
* See lib/philomena/autocomplete.ex for binary structure details.
|
||
|
*
|
||
|
* A binary blob is used to avoid the creation of large amounts of garbage on
|
||
|
* the JS heap and speed up the execution of the search.
|
||
|
*/
|
||
|
export class LocalAutocompleter {
|
||
|
/**
|
||
|
* Build a new local autocompleter.
|
||
|
*
|
||
|
* @param {ArrayBuffer} backingStore
|
||
|
*/
|
||
|
constructor(backingStore) {
|
||
|
/** @type {Uint8Array} */
|
||
|
this.data = new Uint8Array(backingStore);
|
||
|
/** @type {DataView} */
|
||
|
this.view = new DataView(backingStore);
|
||
|
/** @type {TextDecoder} */
|
||
|
this.decoder = new TextDecoder();
|
||
|
/** @type {number} */
|
||
|
this.numTags = this.view.getUint32(backingStore.byteLength - 4, true);
|
||
|
/** @type {number} */
|
||
|
this.referenceStart = this.view.getUint32(backingStore.byteLength - 8, true);
|
||
|
/** @type {number} */
|
||
|
this.formatVersion = this.view.getUint32(backingStore.byteLength - 12, true);
|
||
|
|
||
|
if (this.formatVersion !== 1) {
|
||
|
throw new Error('Incompatible autocomplete format version');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get a tag's name and its associations given a byte location inside the file.
|
||
|
*
|
||
|
* @param {number} location
|
||
|
* @returns {[string, number[]]}
|
||
|
*/
|
||
|
getTagFromLocation(location) {
|
||
|
const nameLength = this.view.getUint8(location);
|
||
|
const assnLength = this.view.getUint8(location + 1 + nameLength);
|
||
|
|
||
|
/** @type {number[]} */
|
||
|
const associations = [];
|
||
|
const name = this.decoder.decode(this.data.slice(location + 1, location + nameLength + 1));
|
||
|
|
||
|
for (let i = 0; i < assnLength; i++) {
|
||
|
associations.push(this.view.getUint32(location + 1 + nameLength + i * 4, true));
|
||
|
}
|
||
|
|
||
|
return [ name, associations ];
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get a Result object as the ith tag inside the file.
|
||
|
*
|
||
|
* @param {number} i
|
||
|
* @returns {Result}
|
||
|
*/
|
||
|
getResultAt(i) {
|
||
|
const nameLocation = this.view.getUint32(this.referenceStart + i * 8, true);
|
||
|
const imageCount = this.view.getUint32(this.referenceStart + i * 8 + 4, true);
|
||
|
const [ name, associations ] = this.getTagFromLocation(nameLocation);
|
||
|
|
||
|
return { name, imageCount, associations };
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Find the top k results by image count which match the given string prefix.
|
||
|
*
|
||
|
* @param {string} prefix
|
||
|
* @param {number} k
|
||
|
* @returns {Result[]}
|
||
|
*/
|
||
|
topK(prefix, k) {
|
||
|
/** @type {Result[]} */
|
||
|
const results = [];
|
||
|
|
||
|
/** @type {number[]} */
|
||
|
//@ts-expect-error No type for window.booru yet
|
||
|
const hiddenTags = window.booru.hiddenTagList;
|
||
|
|
||
|
if (prefix === '') {
|
||
|
return results;
|
||
|
}
|
||
|
|
||
|
// Binary search to find last smaller prefix
|
||
|
let l = 0;
|
||
|
let r = this.numTags;
|
||
|
|
||
|
while (l < r - 1) {
|
||
|
const m = (l + (r - l) / 2) | 0;
|
||
|
const { name } = this.getResultAt(m);
|
||
|
|
||
|
if (name.slice(0, prefix.length) >= prefix) {
|
||
|
// too large, go left
|
||
|
r = m;
|
||
|
}
|
||
|
else {
|
||
|
// too small, go right
|
||
|
l = m;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Scan forward until no more matches occur
|
||
|
while (l < this.numTags - 1) {
|
||
|
const result = this.getResultAt(++l);
|
||
|
if (!result.name.startsWith(prefix)) {
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
// Add if no associations are filtered
|
||
|
if (hiddenTags.findIndex(ht => result.associations.includes(ht)) === -1) {
|
||
|
results.push(result);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Sort results by image count
|
||
|
results.sort((a, b) => b.imageCount - a.imageCount);
|
||
|
|
||
|
return results.slice(0, k);
|
||
|
}
|
||
|
}
|