Skip to content

Commit

Permalink
Merge remote-tracking branch 'upstream/master'
Browse files Browse the repository at this point in the history
  • Loading branch information
liamwhite committed Aug 15, 2024
2 parents ec94292 + 6e64e4b commit 0464b32
Show file tree
Hide file tree
Showing 6 changed files with 239 additions and 52 deletions.
2 changes: 1 addition & 1 deletion assets/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export default tsEslint.config(
'no-irregular-whitespace': 2,
'no-iterator': 2,
'no-label-var': 2,
'no-labels': 2,
'no-labels': [2, { allowSwitch: true, allowLoop: true }],
'no-lone-blocks': 2,
'no-lonely-if': 0,
'no-loop-func': 2,
Expand Down
3 changes: 2 additions & 1 deletion assets/js/autocomplete.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,8 @@ function listenAutocomplete() {
}

const suggestions = localAc
.topK(originalTerm, suggestionsCount)
.matchPrefix(originalTerm)
.topK(suggestionsCount)
.map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name }));

if (suggestions.length) {
Expand Down
30 changes: 16 additions & 14 deletions assets/js/utils/__tests__/local-autocompleter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,42 +58,44 @@ describe('Local Autocompleter', () => {
});

it('should return suggestions for exact tag name match', () => {
const result = localAc.topK('safe', defaultK);
expect(result).toEqual([expect.objectContaining({ name: 'safe', imageCount: 6 })]);
const result = localAc.matchPrefix('safe').topK(defaultK);
expect(result).toEqual([expect.objectContaining({ aliasName: 'safe', name: 'safe', imageCount: 6 })]);
});

it('should return suggestion for original tag when passed an alias', () => {
const result = localAc.topK('flowers', defaultK);
expect(result).toEqual([expect.objectContaining({ name: 'flower', imageCount: 1 })]);
const result = localAc.matchPrefix('flowers').topK(defaultK);
expect(result).toEqual([expect.objectContaining({ aliasName: 'flowers', name: 'flower', imageCount: 1 })]);
});

it('should return suggestions sorted by image count', () => {
const result = localAc.topK(termStem, defaultK);
const result = localAc.matchPrefix(termStem).topK(defaultK);
expect(result).toEqual([
expect.objectContaining({ name: 'forest', imageCount: 3 }),
expect.objectContaining({ name: 'fog', imageCount: 1 }),
expect.objectContaining({ name: 'force field', imageCount: 1 }),
expect.objectContaining({ aliasName: 'forest', name: 'forest', imageCount: 3 }),
expect.objectContaining({ aliasName: 'fog', name: 'fog', imageCount: 1 }),
expect.objectContaining({ aliasName: 'force field', name: 'force field', imageCount: 1 }),
]);
});

it('should return namespaced suggestions without including namespace', () => {
const result = localAc.topK('test', defaultK);
expect(result).toEqual([expect.objectContaining({ name: 'artist:test', imageCount: 1 })]);
const result = localAc.matchPrefix('test').topK(defaultK);
expect(result).toEqual([
expect.objectContaining({ aliasName: 'artist:test', name: 'artist:test', imageCount: 1 }),
]);
});

it('should return only the required number of suggestions', () => {
const result = localAc.topK(termStem, 1);
expect(result).toEqual([expect.objectContaining({ name: 'forest', imageCount: 3 })]);
const result = localAc.matchPrefix(termStem).topK(1);
expect(result).toEqual([expect.objectContaining({ aliasName: 'forest', name: 'forest', imageCount: 3 })]);
});

it('should NOT return suggestions associated with hidden tags', () => {
window.booru.hiddenTagList = [1];
const result = localAc.topK(termStem, defaultK);
const result = localAc.matchPrefix(termStem).topK(defaultK);
expect(result).toEqual([]);
});

it('should return empty array for empty prefix', () => {
const result = localAc.topK('', defaultK);
const result = localAc.matchPrefix('').topK(defaultK);
expect(result).toEqual([]);
});
});
Expand Down
70 changes: 70 additions & 0 deletions assets/js/utils/__tests__/unique-heap.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { UniqueHeap } from '../unique-heap';

describe('Unique Heap', () => {
interface Result {
name: string;
}

function compare(a: Result, b: Result): boolean {
return a.name < b.name;
}

test('it should return no results when empty', () => {
const heap = new UniqueHeap<Result>(compare, 'name');
expect(heap.topK(5)).toEqual([]);
});

test("doesn't insert duplicate results", () => {
const heap = new UniqueHeap<Result>(compare, 'name');

heap.append({ name: 'name' });
heap.append({ name: 'name' });

expect(heap.topK(2)).toEqual([expect.objectContaining({ name: 'name' })]);
});

test('it should return results in reverse sorted order', () => {
const heap = new UniqueHeap<Result>(compare, 'name');

const names = [
'alpha',
'beta',
'gamma',
'delta',
'epsilon',
'zeta',
'eta',
'theta',
'iota',
'kappa',
'lambda',
'mu',
'nu',
'xi',
'omicron',
'pi',
'rho',
'sigma',
'tau',
'upsilon',
'phi',
'chi',
'psi',
'omega',
];

for (const name of names) {
heap.append({ name });
}

const results = heap.topK(5);

expect(results).toEqual([
expect.objectContaining({ name: 'zeta' }),
expect.objectContaining({ name: 'xi' }),
expect.objectContaining({ name: 'upsilon' }),
expect.objectContaining({ name: 'theta' }),
expect.objectContaining({ name: 'tau' }),
]);
});
});
90 changes: 54 additions & 36 deletions assets/js/utils/local-autocompleter.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
// Client-side tag completion.
import { UniqueHeap } from './unique-heap';
import store from './store';

interface Result {
export interface Result {
aliasName: string;
name: string;
imageCount: number;
associations: number[];
}

/**
* Returns whether Result a is considered less than Result b.
*/
function compareResult(a: Result, b: Result): boolean {
return a.imageCount === b.imageCount ? a.name > b.name : a.imageCount < b.imageCount;
}

/**
* Compare two strings, C-style.
*/
Expand All @@ -18,10 +27,13 @@ function strcmp(a: string, b: string): number {
* Returns the name of a tag without any namespace component.
*/
function nameInNamespace(s: string): string {
const v = s.split(':', 2);
const first = s.indexOf(':');

if (first !== -1) {
return s.slice(first + 1);
}

if (v.length === 2) return v[1];
return v[0];
return s;
}

/**
Expand Down Expand Up @@ -59,7 +71,7 @@ export class LocalAutocompleter {
/**
* Get a tag's name and its associations given a byte location inside the file.
*/
getTagFromLocation(location: number): [string, number[]] {
private getTagFromLocation(location: number, imageCount: number, aliasName?: string): Result {
const nameLength = this.view.getUint8(location);
const assnLength = this.view.getUint8(location + 1 + nameLength);

Expand All @@ -70,53 +82,52 @@ export class LocalAutocompleter {
associations.push(this.view.getUint32(location + 1 + nameLength + 1 + i * 4, true));
}

return [name, associations];
return { aliasName: aliasName || name, name, imageCount, associations };
}

/**
* Get a Result object as the ith tag inside the file.
*/
getResultAt(i: number): [string, Result] {
const nameLocation = this.view.getUint32(this.referenceStart + i * 8, true);
private getResultAt(i: number, aliasName?: string): Result {
const tagLocation = 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);
const result = this.getTagFromLocation(tagLocation, imageCount, aliasName);

if (imageCount < 0) {
// This is actually an alias, so follow it
return [name, this.getResultAt(-imageCount - 1)[1]];
return this.getResultAt(-imageCount - 1, aliasName || result.name);
}

return [name, { name, imageCount, associations }];
return result;
}

/**
* Get a Result object as the ith tag inside the file, secondary ordering.
*/
getSecondaryResultAt(i: number): [string, Result] {
private getSecondaryResultAt(i: number): Result {
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.
*/
scanResults(
getResult: (i: number) => [string, Result],
private scanResults(
getResult: (i: number) => Result,
compare: (name: string) => number,
results: Record<string, Result>,
results: UniqueHeap<Result>,
hiddenTags: Set<number>,
) {
const unfilter = store.get('unfilter_tag_suggestions');
const filter = !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];
const med = min + (((max - min) / 2) | 0);
const result = getResult(med);

if (compare(sortKey) >= 0) {
if (compare(result.aliasName) >= 0) {
// too large, go left
max = med;
} else {
Expand All @@ -126,40 +137,47 @@ export class LocalAutocompleter {
}

// Scan forward until no more matches occur
while (min < this.numTags - 1) {
const [sortKey, result] = getResult(++min);
if (compare(sortKey) !== 0) {
outer: while (min < this.numTags - 1) {
const result = getResult(++min);

if (compare(result.aliasName) !== 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;
// Check if any associations are filtered
if (filter) {
for (const association of result.associations) {
if (hiddenTags.has(association)) {
continue outer;
}
}
}

// Nothing was filtered, so add
results.append(result);
}
}

/**
* Find the top k results by image count which match the given string prefix.
*/
topK(prefix: string, k: number): Result[] {
const results: Record<string, Result> = {};
matchPrefix(prefix: string): UniqueHeap<Result> {
const results = new UniqueHeap<Result>(compareResult, 'name');

if (prefix === '') {
return [];
return results;
}

const hiddenTags = new Set(window.booru.hiddenTagList);

// Find normally, in full name-sorted order
const prefixMatch = (name: string) => strcmp(name.slice(0, prefix.length), prefix);
this.scanResults(this.getResultAt.bind(this), prefixMatch, results);
this.scanResults(this.getResultAt.bind(this), prefixMatch, results, hiddenTags);

// Find in secondary order
const namespaceMatch = (name: string) => 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);
this.scanResults(this.getSecondaryResultAt.bind(this), namespaceMatch, results, hiddenTags);

return sorted.slice(0, k);
return results;
}
}
Loading

0 comments on commit 0464b32

Please sign in to comment.