mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-30 23:08:00 +01:00
190 lines
5.2 KiB
JavaScript
190 lines
5.2 KiB
JavaScript
// Client-side tag completion.
|
|
import store from './store';
|
|
|
|
/**
|
|
* @typedef {object} Result
|
|
* @property {string} name
|
|
* @property {number} imageCount
|
|
* @property {number[]} associations
|
|
*/
|
|
|
|
/**
|
|
* Compare two strings, C-style.
|
|
*
|
|
* @param {string} a
|
|
* @param {string} b
|
|
* @returns {number}
|
|
*/
|
|
function strcmp(a, b) {
|
|
return a < b ? -1 : Number(a > b);
|
|
}
|
|
|
|
/**
|
|
* Returns the name of a tag without any namespace component.
|
|
*
|
|
* @param {string} s
|
|
* @returns {string}
|
|
*/
|
|
function nameInNamespace(s) {
|
|
const v = s.split(':', 2);
|
|
|
|
if (v.length === 2) return v[1];
|
|
return v[0];
|
|
}
|
|
|
|
/**
|
|
* 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.secondaryStart = this.referenceStart + 8 * this.numTags;
|
|
/** @type {number} */
|
|
this.formatVersion = this.view.getUint32(backingStore.byteLength - 12, true);
|
|
|
|
if (this.formatVersion !== 2) {
|
|
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 + 1 + i * 4, true));
|
|
}
|
|
|
|
return [ name, associations ];
|
|
}
|
|
|
|
/**
|
|
* Get a Result object as the ith tag inside the file.
|
|
*
|
|
* @param {number} i
|
|
* @returns {[string, Result]}
|
|
*/
|
|
getResultAt(i) {
|
|
const nameLocation = this.view.getUint32(this.referenceStart + i * 8, true);
|
|
const imageCount = this.view.getInt32(this.referenceStart + i * 8 + 4, true);
|
|
const [ name, associations ] = this.getTagFromLocation(nameLocation);
|
|
|
|
if (imageCount < 0) {
|
|
// This is actually an alias, so follow it
|
|
return [ name, this.getResultAt(-imageCount - 1)[1] ];
|
|
}
|
|
|
|
return [ name, { name, imageCount, associations } ];
|
|
}
|
|
|
|
/**
|
|
* Get a Result object as the ith tag inside the file, secondary ordering.
|
|
*
|
|
* @param {number} i
|
|
* @returns {[string, Result]}
|
|
*/
|
|
getSecondaryResultAt(i) {
|
|
const referenceIndex = this.view.getUint32(this.secondaryStart + i * 4, true);
|
|
return this.getResultAt(referenceIndex);
|
|
}
|
|
|
|
/**
|
|
* Perform a binary search to fetch all results matching a condition.
|
|
*
|
|
* @param {(i: number) => [string, Result]} getResult
|
|
* @param {(name: string) => number} compare
|
|
* @param {{[key: string]: Result}} results
|
|
*/
|
|
scanResults(getResult, compare, results) {
|
|
const unfilter = store.get('unfilter_tag_suggestions');
|
|
|
|
let min = 0;
|
|
let max = this.numTags;
|
|
|
|
const hiddenTags = window.booru.hiddenTagList;
|
|
|
|
while (min < max - 1) {
|
|
const med = (min + (max - min) / 2) | 0;
|
|
const sortKey = getResult(med)[0];
|
|
|
|
if (compare(sortKey) >= 0) {
|
|
// too large, go left
|
|
max = med;
|
|
}
|
|
else {
|
|
// too small, go right
|
|
min = med;
|
|
}
|
|
}
|
|
|
|
// Scan forward until no more matches occur
|
|
while (min < this.numTags - 1) {
|
|
const [ sortKey, result ] = getResult(++min);
|
|
if (compare(sortKey) !== 0) {
|
|
break;
|
|
}
|
|
|
|
// Add if not filtering or no associations are filtered
|
|
if (unfilter || hiddenTags.findIndex(ht => result.associations.includes(ht)) === -1) {
|
|
results[result.name] = result;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 {{[key: string]: Result}} */
|
|
const results = {};
|
|
|
|
if (prefix === '') {
|
|
return [];
|
|
}
|
|
|
|
// Find normally, in full name-sorted order
|
|
const prefixMatch = (/** @type {string} */ name) => strcmp(name.slice(0, prefix.length), prefix);
|
|
this.scanResults(this.getResultAt.bind(this), prefixMatch, results);
|
|
|
|
// Find in secondary order
|
|
const namespaceMatch = (/** @type {string} */ name) => strcmp(nameInNamespace(name).slice(0, prefix.length), prefix);
|
|
this.scanResults(this.getSecondaryResultAt.bind(this), namespaceMatch, results);
|
|
|
|
// Sort results by image count
|
|
const sorted = Object.values(results).sort((a, b) => b.imageCount - a.imageCount);
|
|
|
|
return sorted.slice(0, k);
|
|
}
|
|
}
|