convert array, dom and tag utils to typescript

This commit is contained in:
SeinopSys 2022-03-25 22:03:42 +01:00 committed by Luna D
parent c401695513
commit 0628eb0c23
No known key found for this signature in database
GPG key ID: 4B1C63448394F688
12 changed files with 1208 additions and 8268 deletions

View file

@ -2,12 +2,25 @@ import { $ } from './utils/dom';
import parseSearch from './match_query';
import store from './utils/store';
/* Store a tag locally, marking the retrieval time */
/**
* Store a tag locally, marking the retrieval time
* @param {TagData} tagData
*/
function persistTag(tagData) {
tagData.fetchedAt = new Date().getTime() / 1000;
store.set(`bor_tags_${tagData.id}`, tagData);
/**
* @type {TagData}
*/
const persistData = {
...tagData,
fetchedAt: new Date().getTime() / 1000,
};
store.set(`bor_tags_${tagData.id}`, persistData);
}
/**
* @param {TagData} tag
* @return {boolean}
*/
function isStale(tag) {
const now = new Date().getTime() / 1000;
return tag.fetchedAt === null || tag.fetchedAt < (now - 604800);
@ -21,11 +34,31 @@ function clearTags() {
});
}
/* Returns a single tag, or a dummy tag object if we don't know about it yet */
/**
* @param {unknown} value
* @returns {value is TagData}
*/
function isValidStoredTag(value) {
if ('id' in value && 'name' in value && 'images' in value && 'spoiler_image_uri' in value) {
return typeof value.id === 'number'
&& typeof value.name === 'string'
&& typeof value.images === 'number'
&& (value.spoiler_image_uri === null || typeof value.spoiler_image_uri === 'string')
&& (value.fetchedAt === null || typeof value.fetchedAt === 'number');
}
return false;
}
/**
* Returns a single tag, or a dummy tag object if we don't know about it yet
* @param {number} tagId
* @returns {TagData}
*/
function getTag(tagId) {
const stored = store.get(`bor_tags_${tagId}`);
if (stored) {
if (isValidStoredTag(stored)) {
return stored;
}
@ -34,10 +67,14 @@ function getTag(tagId) {
name: '(unknown tag)',
images: 0,
spoiler_image_uri: null,
fetchedAt: null,
};
}
/* Fetches lots of tags in batches and stores them locally */
/**
* Fetches lots of tags in batches and stores them locally
* @param {number[]} tagIds
*/
function fetchAndPersistTags(tagIds) {
if (!tagIds.length) return;
@ -50,7 +87,10 @@ function fetchAndPersistTags(tagIds) {
.then(() => fetchAndPersistTags(remaining));
}
/* Figure out which tags in the list we don't know about */
/**
* Figure out which tags in the list we don't know about
* @param {number[]} tagIds
*/
function fetchNewOrStaleTags(tagIds) {
const fetchIds = [];

View file

@ -44,6 +44,7 @@ describe('Array Utilities', () => {
moveElement(input, 2, 1);
expect(input).toEqual(['a', 'c', 'b', 'd']);
});
it('should work with ascending index parameters', () => {
const input = ['a', 'b', 'c', 'd'];
moveElement(input, 1, 2);
@ -88,50 +89,48 @@ describe('Array Utilities', () => {
['', null, false, uniqueValue, mockObject, Infinity, undefined]
)).toBe(true);
});
});
it('should return true for matching up to the first array\'s length', () => {
describe('negative cases', () => {
it('should NOT return true for matching only up to the first array\'s length', () => {
// Numbers
expect(arraysEqual([0], [0, 1])).toBe(true);
expect(arraysEqual([0, 1], [0, 1, 2])).toBe(true);
expect(arraysEqual([0], [0, 1])).toBe(false);
expect(arraysEqual([0, 1], [0, 1, 2])).toBe(false);
// Strings
expect(arraysEqual(['a'], ['a', 'b'])).toBe(true);
expect(arraysEqual(['a', 'b'], ['a', 'b', 'c'])).toBe(true);
expect(arraysEqual(['a'], ['a', 'b'])).toBe(false);
expect(arraysEqual(['a', 'b'], ['a', 'b', 'c'])).toBe(false);
// Object by reference
const uniqueValue1 = Symbol('item1');
const uniqueValue2 = Symbol('item2');
expect(arraysEqual([uniqueValue1], [uniqueValue1, uniqueValue2])).toBe(true);
expect(arraysEqual([uniqueValue1], [uniqueValue1, uniqueValue2])).toBe(false);
// Mixed parameters
const mockObject = { value: Math.random() };
expect(arraysEqual(
[''],
['', null, false, mockObject, Infinity, undefined]
)).toBe(true);
)).toBe(false);
expect(arraysEqual(
['', null],
['', null, false, mockObject, Infinity, undefined]
)).toBe(true);
)).toBe(false);
expect(arraysEqual(
['', null, false],
['', null, false, mockObject, Infinity, undefined]
)).toBe(true);
)).toBe(false);
expect(arraysEqual(
['', null, false, mockObject],
['', null, false, mockObject, Infinity, undefined]
)).toBe(true);
)).toBe(false);
expect(arraysEqual(
['', null, false, mockObject, Infinity],
['', null, false, mockObject, Infinity, undefined]
)).toBe(true);
});
)).toBe(false);
});
describe('negative cases', () => {
// FIXME This case should be handled
// eslint-disable-next-line jest/no-disabled-tests
it.skip('should return false for arrays of different length', () => {
it('should return false for arrays of different length', () => {
// Numbers
expect(arraysEqual([], [0])).toBe(false);
expect(arraysEqual([0], [])).toBe(false);

View file

@ -245,11 +245,6 @@ describe('DOM Utilities', () => {
jest.restoreAllMocks();
});
it('should throw error if element has no parent', () => {
const detachedElement = document.createElement('div');
expect(() => removeEl(detachedElement)).toThrow(/propert(y|ies).*null/);
});
it('should call the native removeElement method on parent', () => {
const parentNode = document.createElement('div');
const childNode = document.createElement('p');
@ -310,6 +305,7 @@ describe('DOM Utilities', () => {
expect(mockParent.children[0].tagName).toBe('STRONG');
expect(mockParent.children[1].tagName).toBe('SPAN');
});
it('should insert between two elements', () => {
const mockParent = document.createElement('p');
const mockFirstExisingElement = document.createElement('span');
@ -325,15 +321,6 @@ describe('DOM Utilities', () => {
expect(mockParent.children[1].tagName).toBe('STRONG');
expect(mockParent.children[2].tagName).toBe('EM');
});
it('should fail if there is no parent', () => {
const mockParent = document.createElement('p');
const mockNewElement = document.createElement('em');
expect(() => {
insertBefore(mockParent, mockNewElement);
}).toThrow(/propert(y|ies).*null/);
});
});
describe('onLeftClick', () => {

View file

@ -1,48 +1,45 @@
import { displayTags, getHiddenTags, getSpoileredTags, imageHitsComplex, imageHitsTags } from '../tag';
import { displayTags, getHiddenTags, getSpoileredTags, imageHitsComplex, imageHitsTags, TagData } from '../tag';
import { mockStorage } from '../../../test/mock-storage';
import { getRandomArrayItem } from '../../../test/randomness';
import parseSearch from '../../match_query';
// TODO Move to source file when rewriting in TypeScript
interface StorageTagInfo {
id: number;
name: string;
images: number;
spoiler_image_uri: string | null;
}
describe('Tag utilities', () => {
const tagStorageKeyPrefix = 'bor_tags_';
const mockTagInfo: Record<string, StorageTagInfo> = {
const mockTagInfo: Record<string, TagData> = {
1: {
id: 1,
name: 'safe',
images: 69,
spoiler_image_uri: null,
fetchedAt: null,
},
2: {
id: 2,
name: 'fox',
images: 1,
spoiler_image_uri: '/mock-fox-spoiler-image.svg',
fetchedAt: null,
},
3: {
id: 3,
name: 'paw pads',
images: 42,
spoiler_image_uri: '/mock-paw-pads-spoiler-image.svg',
fetchedAt: null,
},
4: {
id: 4,
name: 'whiskers',
images: 42,
spoiler_image_uri: null,
fetchedAt: null,
},
5: {
id: 5,
name: 'lilo & stitch',
images: 6,
spoiler_image_uri: null,
fetchedAt: null,
},
};
const getEnabledSpoilerType = () => getRandomArrayItem<SpoilerType>(['click', 'hover', 'static']);

View file

@ -1,11 +0,0 @@
// http://stackoverflow.com/a/5306832/1726690
export function moveElement(array, from, to) {
array.splice(to, 0, array.splice(from, 1)[0]);
}
export function arraysEqual(array1, array2) {
for (let i = 0; i < array1.length; ++i) {
if (array1[i] !== array2[i]) return false;
}
return true;
}

10
assets/js/utils/array.ts Normal file
View file

@ -0,0 +1,10 @@
// http://stackoverflow.com/a/5306832/1726690
export function moveElement<Items>(array: Items[], from: number, to: number): void {
array.splice(to, 0, array.splice(from, 1)[0]);
}
export function arraysEqual(array1: unknown[], array2: unknown[]): boolean {
if (array1.length !== array2.length) return false;
return array1.every((item, index) => item === array2[index]);
}

View file

@ -1,74 +0,0 @@
/**
* DOM Utils
*/
function $(selector, context = document) { // Get the first matching element
const element = context.querySelector(selector);
return element || null;
}
function $$(selector, context = document) { // Get every matching element as an array
const elements = context.querySelectorAll(selector);
return [].slice.call(elements);
}
function showEl(...elements) {
[].concat(...elements).forEach(el => el.classList.remove('hidden'));
}
function hideEl(...elements) {
[].concat(...elements).forEach(el => el.classList.add('hidden'));
}
function toggleEl(...elements) {
[].concat(...elements).forEach(el => el.classList.toggle('hidden'));
}
function clearEl(...elements) {
[].concat(...elements).forEach(el => { while (el.firstChild) el.removeChild(el.firstChild); });
}
function removeEl(...elements) {
[].concat(...elements).forEach(el => el.parentNode.removeChild(el));
}
function makeEl(tag, attr = {}) {
const el = document.createElement(tag);
for (const prop in attr) el[prop] = attr[prop];
return el;
}
function insertBefore(existingElement, newElement) {
existingElement.parentNode.insertBefore(newElement, existingElement);
}
function onLeftClick(callback, context = document) {
context.addEventListener('click', event => {
if (event.button === 0) callback(event);
});
}
function whenReady(callback) { // Execute a function when the DOM is ready
if (document.readyState !== 'loading') callback();
else document.addEventListener('DOMContentLoaded', callback);
}
function escapeHtml(html) {
return html.replace(/&/g, '&amp;')
.replace(/>/g, '&gt;')
.replace(/</g, '&lt;')
.replace(/"/g, '&quot;');
}
function escapeCss(css) {
return css.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"');
}
function findFirstTextNode(of) {
return Array.prototype.filter.call(of.childNodes, el => el.nodeType === Node.TEXT_NODE)[0];
}
export { $, $$, showEl, hideEl, toggleEl, clearEl, removeEl, makeEl, insertBefore, onLeftClick, whenReady, escapeHtml, escapeCss, findFirstTextNode };

95
assets/js/utils/dom.ts Normal file
View file

@ -0,0 +1,95 @@
/**
* DOM Utils
*/
/**
* Get the first matching element
*/
export function $<E extends Element = Element>(selector: string, context: Pick<Document, 'querySelector'> = document): E | null {
return context.querySelector<E>(selector);
}
/**
* Get every matching element as an array
*/
export function $$<E extends Element = Element>(selector: string, context: Pick<Document, 'querySelectorAll'> = document): E[] {
const elements = context.querySelectorAll<E>(selector);
return [...elements];
}
export function showEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => el.classList.remove('hidden'));
}
export function hideEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => el.classList.add('hidden'));
}
export function toggleEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => el.classList.toggle('hidden'));
}
export function clearEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => {
while (el.firstChild) el.removeChild(el.firstChild);
});
}
export function removeEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => el.parentNode?.removeChild(el));
}
export function makeEl<Tag extends keyof HTMLElementTagNameMap>(tag: Tag, attr?: Partial<HTMLElementTagNameMap[Tag]>): HTMLElementTagNameMap[Tag] {
const el = document.createElement(tag);
if (attr) {
for (const prop in attr) {
const newValue = attr[prop];
if (typeof newValue !== 'undefined') {
el[prop] = newValue as Exclude<typeof newValue, undefined>;
}
}
}
return el;
}
export function insertBefore(existingElement: HTMLElement, newElement: HTMLElement) {
existingElement.parentNode?.insertBefore(newElement, existingElement);
}
export function onLeftClick(callback: (e: MouseEvent) => boolean | void, context: Pick<GlobalEventHandlers, 'addEventListener' | 'removeEventListener'> = document): VoidFunction {
const handler: typeof callback = event => {
if (event.button === 0) callback(event);
};
context.addEventListener('click', handler);
return () => context.removeEventListener('click', handler);
}
/**
* Execute a function when the DOM is ready
*/
export function whenReady(callback: VoidFunction): void {
if (document.readyState !== 'loading') {
callback();
}
else {
document.addEventListener('DOMContentLoaded', callback);
}
}
export function escapeHtml(html: string): string {
return html.replace(/&/g, '&amp;')
.replace(/>/g, '&gt;')
.replace(/</g, '&lt;')
.replace(/"/g, '&quot;');
}
export function escapeCss(css: string): string {
return css.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"');
}
export function findFirstTextNode<N extends Node>(of: Node): N {
return Array.prototype.filter.call(of.childNodes, el => el.nodeType === Node.TEXT_NODE)[0];
}

View file

@ -1,11 +1,19 @@
import { escapeHtml } from './dom';
import { getTag } from '../booru';
function unique(array) {
export interface TagData {
id: number;
name: string;
images: number;
spoiler_image_uri: string | null;
fetchedAt: null | number;
}
function unique<Item>(array: Item[]): Item[] {
return array.filter((a, b, c) => c.indexOf(a) === b);
}
function sortTags(hidden, a, b) {
function sortTags(hidden: boolean, a: TagData, b: TagData): number {
// If both tags have a spoiler image, sort by images count desc (hidden) or asc (spoilered)
if (a.spoiler_image_uri && b.spoiler_image_uri) {
return hidden ? b.images - a.images : a.images - b.images;
@ -34,16 +42,20 @@ export function getSpoileredTags() {
.sort(sortTags.bind(null, false));
}
export function imageHitsTags(img, matchTags) {
const imageTags = JSON.parse(img.dataset.imageTags);
export function imageHitsTags(img: HTMLImageElement, matchTags: TagData[]): TagData[] {
const imageTagsString = img.dataset.imageTags;
if (typeof imageTagsString === 'undefined') {
return [];
}
const imageTags = JSON.parse(imageTagsString);
return matchTags.filter(t => imageTags.indexOf(t.id) !== -1);
}
export function imageHitsComplex(img, matchComplex) {
export function imageHitsComplex(img: HTMLImageElement, matchComplex: { hitsImage: (img: HTMLImageElement) => boolean }) {
return matchComplex.hitsImage(img);
}
export function displayTags(tags) {
export function displayTags(tags: TagData[]): string {
const mainTag = tags[0], otherTags = tags.slice(1);
let list = escapeHtml(mainTag.name), extras;

9142
assets/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -51,6 +51,7 @@ interface BooruObject {
* @type {import('../js/match_query.js').SearchAST}
*/
spoileredFilter: unknown;
tagsVersion: number;
}
interface Window {