mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-30 14:57:59 +01:00
convert array, dom and tag utils to typescript
This commit is contained in:
parent
c401695513
commit
0628eb0c23
12 changed files with 1208 additions and 8268 deletions
|
@ -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 = [];
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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']);
|
||||
|
|
|
@ -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
10
assets/js/utils/array.ts
Normal 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]);
|
||||
}
|
|
@ -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, '&')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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
95
assets/js/utils/dom.ts
Normal 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, '&')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
|
@ -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
9142
assets/package-lock.json
generated
File diff suppressed because it is too large
Load diff
1
assets/types/booru-object.d.ts
vendored
1
assets/types/booru-object.d.ts
vendored
|
@ -51,6 +51,7 @@ interface BooruObject {
|
|||
* @type {import('../js/match_query.js').SearchAST}
|
||||
*/
|
||||
spoileredFilter: unknown;
|
||||
tagsVersion: number;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
|
Loading…
Reference in a new issue