From bbc1879a1ecb5fa393cc2e342c06fd9035bcf2c0 Mon Sep 17 00:00:00 2001 From: Luna D Date: Thu, 6 Jun 2024 20:44:41 -0400 Subject: [PATCH 1/2] Convert clientside image filtering scripts to TypeScript --- assets/js/imagesclientside.js | 76 ------------------------ assets/js/imagesclientside.ts | 109 ++++++++++++++++++++++++++++++++++ assets/js/utils/tag.ts | 4 +- 3 files changed, 111 insertions(+), 78 deletions(-) delete mode 100644 assets/js/imagesclientside.js create mode 100644 assets/js/imagesclientside.ts diff --git a/assets/js/imagesclientside.js b/assets/js/imagesclientside.js deleted file mode 100644 index 7dc3fb31..00000000 --- a/assets/js/imagesclientside.js +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Client-side image filtering/spoilering. - */ - -import { $$, escapeHtml } from './utils/dom'; -import { setupInteractions } from './interactions'; -import { showThumb, showBlock, spoilerThumb, spoilerBlock, hideThumb } from './utils/image'; -import { getHiddenTags, getSpoileredTags, imageHitsTags, imageHitsComplex, displayTags } from './utils/tag'; - -function runFilter(img, test, runCallback) { - if (!test || test.length === 0) return false; - - runCallback(img, test); - - // I don't like this. - window.booru.imagesWithDownvotingDisabled.push(img.dataset.imageId); - - return true; -} - -// --- - -function filterThumbSimple(img, tagsHit) { hideThumb(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `[HIDDEN] ${displayTags(tagsHit)}`); } -function spoilerThumbSimple(img, tagsHit) { spoilerThumb(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, displayTags(tagsHit)); } -function filterThumbComplex(img) { hideThumb(img, window.booru.hiddenTag, '[HIDDEN] (Complex Filter)'); } -function spoilerThumbComplex(img) { spoilerThumb(img, window.booru.hiddenTag, '(Complex Filter)'); } - -function filterBlockSimple(img, tagsHit) { spoilerBlock(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `This image is tagged ${escapeHtml(tagsHit[0].name)}, which is hidden by `); } -function spoilerBlockSimple(img, tagsHit) { spoilerBlock(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `This image is tagged ${escapeHtml(tagsHit[0].name)}, which is spoilered by `); } -function filterBlockComplex(img) { spoilerBlock(img, window.booru.hiddenTag, 'This image was hidden by a complex tag expression in '); } -function spoilerBlockComplex(img) { spoilerBlock(img, window.booru.hiddenTag, 'This image was spoilered by a complex tag expression in '); } - -// --- - -function thumbTagFilter(tags, img) { return runFilter(img, imageHitsTags(img, tags), filterThumbSimple); } -function thumbComplexFilter(complex, img) { return runFilter(img, imageHitsComplex(img, complex), filterThumbComplex); } -function thumbTagSpoiler(tags, img) { return runFilter(img, imageHitsTags(img, tags), spoilerThumbSimple); } -function thumbComplexSpoiler(complex, img) { return runFilter(img, imageHitsComplex(img, complex), spoilerThumbComplex); } - -function blockTagFilter(tags, img) { return runFilter(img, imageHitsTags(img, tags), filterBlockSimple); } -function blockComplexFilter(complex, img) { return runFilter(img, imageHitsComplex(img, complex), filterBlockComplex); } -function blockTagSpoiler(tags, img) { return runFilter(img, imageHitsTags(img, tags), spoilerBlockSimple); } -function blockComplexSpoiler(complex, img) { return runFilter(img, imageHitsComplex(img, complex), spoilerBlockComplex); } - -// --- - -function filterNode(node = document) { - const hiddenTags = getHiddenTags(), spoileredTags = getSpoileredTags(); - const { hiddenFilter, spoileredFilter } = window.booru; - - // Image thumb boxes with vote and fave buttons on them - $$('.image-container', node) - .filter(img => !thumbTagFilter(hiddenTags, img)) - .filter(img => !thumbComplexFilter(hiddenFilter, img)) - .filter(img => !thumbTagSpoiler(spoileredTags, img)) - .filter(img => !thumbComplexSpoiler(spoileredFilter, img)) - .forEach(img => showThumb(img)); - - // Individual image pages and images in posts/comments - $$('.image-show-container', node) - .filter(img => !blockTagFilter(hiddenTags, img)) - .filter(img => !blockComplexFilter(hiddenFilter, img)) - .filter(img => !blockTagSpoiler(spoileredTags, img)) - .filter(img => !blockComplexSpoiler(spoileredFilter, img)) - .forEach(img => showBlock(img)); -} - -function initImagesClientside() { - window.booru.imagesWithDownvotingDisabled = []; - // This fills the imagesWithDownvotingDisabled array - filterNode(document); - // Once the array is populated, we can initialize interactions - setupInteractions(); -} - -export { initImagesClientside, filterNode }; diff --git a/assets/js/imagesclientside.ts b/assets/js/imagesclientside.ts new file mode 100644 index 00000000..61e1d1ac --- /dev/null +++ b/assets/js/imagesclientside.ts @@ -0,0 +1,109 @@ +/** + * Client-side image filtering/spoilering. + */ + +import { $$, escapeHtml } from './utils/dom'; +import { setupInteractions } from './interactions'; +import { showThumb, showBlock, spoilerThumb, spoilerBlock, hideThumb } from './utils/image'; +import { TagData, getHiddenTags, getSpoileredTags, imageHitsTags, imageHitsComplex, displayTags } from './utils/tag'; +import { AstMatcher } from './query/types'; + +type CallbackType = 'tags' | 'complex'; +type RunCallback = (img: HTMLDivElement, tags: TagData[], type: CallbackType) => void; + +function run( + img: HTMLDivElement, + tags: TagData[], + complex: AstMatcher, + callback: RunCallback, +): boolean { + const hit = (() => { + // Check tags array first to provide more precise filter explanations + const hitTags = imageHitsTags(img, tags); + if (hitTags.length !== 0) { + callback(img, hitTags, 'tags'); + return true; + } + + // No tags matched, try complex filter AST + const hitComplex = imageHitsComplex(img, complex); + if (hitComplex) { + callback(img, hitTags, 'complex'); + return true; + } + + // Nothing matched at all, image can be shown + return false; + })(); + + if (hit && img.dataset.imageId) { + // Disallow negative interaction on image which is not visible + window.booru.imagesWithDownvotingDisabled.push(img.dataset.imageId); + } + + return hit; +} + +function bannerImage(tagsHit: TagData[]) { + if (tagsHit.length > 0) { + return tagsHit[0].spoiler_image_uri || window.booru.hiddenTag; + } + + return window.booru.hiddenTag; +} + +// TODO: this approach is not suitable for translations because it depends on +// markup embedded in the page adjacent to this text + +/* eslint-disable indent */ + +function hideThumbTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) { + const bannerText = type === 'tags' ? `[HIDDEN] ${displayTags(tagsHit)}` + : '[HIDDEN] (Complex Filter)'; + hideThumb(img, bannerImage(tagsHit), bannerText); +} + +function spoilerThumbTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) { + const bannerText = type === 'tags' ? displayTags(tagsHit) + : '(Complex Filter)'; + spoilerThumb(img, bannerImage(tagsHit), bannerText); +} + +function hideBlockTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) { + const bannerText = type === 'tags' ? `This image is tagged ${escapeHtml(tagsHit[0].name)}, which is hidden by ` + : 'This image was hidden by a complex tag expression in '; + spoilerBlock(img, bannerImage(tagsHit), bannerText); +} + +function spoilerBlockTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) { + const bannerText = type === 'tags' ? `This image is tagged ${escapeHtml(tagsHit[0].name)}, which is spoilered by ` + : 'This image was spoilered by a complex tag expression in '; + spoilerBlock(img, bannerImage(tagsHit), bannerText); +} + +/* eslint-enable indent */ + +export function filterNode(node: Pick) { + const hiddenTags = getHiddenTags(), spoileredTags = getSpoileredTags(); + const { hiddenFilter, spoileredFilter } = window.booru; + + // Image thumb boxes with vote and fave buttons on them + $$('.image-container', node) + .filter(img => !run(img, hiddenTags, hiddenFilter, hideThumbTyped)) + .filter(img => !run(img, spoileredTags, spoileredFilter, spoilerThumbTyped)) + .forEach(img => showThumb(img)); + + // Individual image pages and images in posts/comments + $$('.image-show-container', node) + .filter(img => !run(img, hiddenTags, hiddenFilter, hideBlockTyped)) + .filter(img => !run(img, spoileredTags, spoileredFilter, spoilerBlockTyped)) + .forEach(img => showBlock(img)); +} + +export function initImagesClientside() { + window.booru.imagesWithDownvotingDisabled = []; + // This fills the imagesWithDownvotingDisabled array + filterNode(document); + // Once the array is populated, we can initialize interactions + setupInteractions(); +} diff --git a/assets/js/utils/tag.ts b/assets/js/utils/tag.ts index ea104213..26c6bdf9 100644 --- a/assets/js/utils/tag.ts +++ b/assets/js/utils/tag.ts @@ -28,13 +28,13 @@ function sortTags(hidden: boolean, a: TagData, b: TagData): number { return a.spoiler_image_uri ? -1 : 1; } -export function getHiddenTags() { +export function getHiddenTags(): TagData[] { return unique(window.booru.hiddenTagList) .map(tagId => getTag(tagId)) .sort(sortTags.bind(null, true)); } -export function getSpoileredTags() { +export function getSpoileredTags(): TagData[] { if (window.booru.spoilerType === 'off') return []; return unique(window.booru.spoileredTagList) From d0771c7216589578794f3e681000ed65aa990c03 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 17 Jun 2024 19:36:41 -0400 Subject: [PATCH 2/2] Add tests for clientside image filtering --- assets/js/__tests__/imagesclientside.spec.ts | 161 +++++++++++++++++++ assets/js/imagesclientside.ts | 11 +- 2 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 assets/js/__tests__/imagesclientside.spec.ts diff --git a/assets/js/__tests__/imagesclientside.spec.ts b/assets/js/__tests__/imagesclientside.spec.ts new file mode 100644 index 00000000..3f3feb88 --- /dev/null +++ b/assets/js/__tests__/imagesclientside.spec.ts @@ -0,0 +1,161 @@ +import { filterNode, initImagesClientside } from '../imagesclientside'; +import { parseSearch } from '../match_query'; +import { matchNone } from '../query/boolean'; +import { assertNotNull } from '../utils/assert'; +import { $ } from '../utils/dom'; + +describe('filterNode', () => { + beforeEach(() => { + window.booru.hiddenTagList = []; + window.booru.spoileredTagList = []; + window.booru.ignoredTagList = []; + window.booru.imagesWithDownvotingDisabled = []; + + window.booru.hiddenFilter = matchNone(); + window.booru.spoileredFilter = matchNone(); + }); + + function makeMediaContainer() { + const element = document.createElement('div'); + element.innerHTML = ` +
+
+ +
+ `; + return [ element, assertNotNull($('.js-spoiler-info-overlay', element)) ]; + } + + it('should show image media boxes not matching any filter', () => { + const [ container, spoilerOverlay ] = makeMediaContainer(); + + filterNode(container); + expect(spoilerOverlay).not.toContainHTML('(Complex Filter)'); + expect(spoilerOverlay).not.toContainHTML('(unknown tag)'); + expect(window.booru.imagesWithDownvotingDisabled).not.toContain('1'); + }); + + it('should spoiler media boxes spoilered by a tag filter', () => { + const [ container, spoilerOverlay ] = makeMediaContainer(); + window.booru.spoileredTagList = [1]; + + filterNode(container); + expect(spoilerOverlay).toContainHTML('(unknown tag)'); + expect(window.booru.imagesWithDownvotingDisabled).toContain('1'); + }); + + it('should spoiler media boxes spoilered by a complex filter', () => { + const [ container, spoilerOverlay ] = makeMediaContainer(); + window.booru.spoileredFilter = parseSearch('id:1'); + + filterNode(container); + expect(spoilerOverlay).toContainHTML('(Complex Filter)'); + expect(window.booru.imagesWithDownvotingDisabled).toContain('1'); + }); + + it('should hide media boxes hidden by a tag filter', () => { + const [ container, spoilerOverlay ] = makeMediaContainer(); + window.booru.hiddenTagList = [1]; + + filterNode(container); + expect(spoilerOverlay).toContainHTML('[HIDDEN]'); + expect(spoilerOverlay).toContainHTML('(unknown tag)'); + expect(window.booru.imagesWithDownvotingDisabled).toContain('1'); + }); + + it('should hide media boxes hidden by a complex filter', () => { + const [ container, spoilerOverlay ] = makeMediaContainer(); + window.booru.hiddenFilter = parseSearch('id:1'); + + filterNode(container); + expect(spoilerOverlay).toContainHTML('[HIDDEN]'); + expect(spoilerOverlay).toContainHTML('(Complex Filter)'); + expect(window.booru.imagesWithDownvotingDisabled).toContain('1'); + }); + + function makeImageBlock(): HTMLElement[] { + const element = document.createElement('div'); + element.innerHTML = ` +
+ + +
+ `; + return [ + element, + assertNotNull($('.image-filtered', element)), + assertNotNull($('.image-show', element)), + assertNotNull($('.filter-explanation', element)) + ]; + } + + it('should show image blocks not matching any filter', () => { + const [ container, imageFiltered, imageShow ] = makeImageBlock(); + + filterNode(container); + expect(imageFiltered).toHaveClass('hidden'); + expect(imageShow).not.toHaveClass('hidden'); + expect(window.booru.imagesWithDownvotingDisabled).not.toContain('1'); + }); + + it('should spoiler image blocks spoilered by a tag filter', () => { + const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock(); + window.booru.spoileredTagList = [1]; + + filterNode(container); + expect(imageFiltered).not.toHaveClass('hidden'); + expect(imageShow).toHaveClass('hidden'); + expect(filterExplanation).toContainHTML('spoilered by'); + expect(filterExplanation).toContainHTML('(unknown tag)'); + expect(window.booru.imagesWithDownvotingDisabled).toContain('1'); + }); + + it('should spoiler image blocks spoilered by a complex filter', () => { + const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock(); + window.booru.spoileredFilter = parseSearch('id:1'); + + filterNode(container); + expect(imageFiltered).not.toHaveClass('hidden'); + expect(imageShow).toHaveClass('hidden'); + expect(filterExplanation).toContainHTML('spoilered by'); + expect(filterExplanation).toContainHTML('complex tag expression'); + expect(window.booru.imagesWithDownvotingDisabled).toContain('1'); + }); + + it('should hide image blocks hidden by a tag filter', () => { + const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock(); + window.booru.hiddenTagList = [1]; + + filterNode(container); + expect(imageFiltered).not.toHaveClass('hidden'); + expect(imageShow).toHaveClass('hidden'); + expect(filterExplanation).toContainHTML('hidden by'); + expect(filterExplanation).toContainHTML('(unknown tag)'); + expect(window.booru.imagesWithDownvotingDisabled).toContain('1'); + }); + + it('should hide image blocks hidden by a complex filter', () => { + const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock(); + window.booru.hiddenFilter = parseSearch('id:1'); + + filterNode(container); + expect(imageFiltered).not.toHaveClass('hidden'); + expect(imageShow).toHaveClass('hidden'); + expect(filterExplanation).toContainHTML('hidden by'); + expect(filterExplanation).toContainHTML('complex tag expression'); + expect(window.booru.imagesWithDownvotingDisabled).toContain('1'); + }); + +}); + +describe('initImagesClientside', () => { + it('should initialize the imagesWithDownvotingDisabled array', () => { + initImagesClientside(); + expect(window.booru.imagesWithDownvotingDisabled).toEqual([]); + }); +}); diff --git a/assets/js/imagesclientside.ts b/assets/js/imagesclientside.ts index 61e1d1ac..add108c9 100644 --- a/assets/js/imagesclientside.ts +++ b/assets/js/imagesclientside.ts @@ -2,6 +2,7 @@ * Client-side image filtering/spoilering. */ +import { assertNotUndefined } from './utils/assert'; import { $$, escapeHtml } from './utils/dom'; import { setupInteractions } from './interactions'; import { showThumb, showBlock, spoilerThumb, spoilerBlock, hideThumb } from './utils/image'; @@ -15,20 +16,20 @@ function run( img: HTMLDivElement, tags: TagData[], complex: AstMatcher, - callback: RunCallback, + runCallback: RunCallback ): boolean { const hit = (() => { // Check tags array first to provide more precise filter explanations const hitTags = imageHitsTags(img, tags); if (hitTags.length !== 0) { - callback(img, hitTags, 'tags'); + runCallback(img, hitTags, 'tags'); return true; } // No tags matched, try complex filter AST const hitComplex = imageHitsComplex(img, complex); if (hitComplex) { - callback(img, hitTags, 'complex'); + runCallback(img, hitTags, 'complex'); return true; } @@ -36,9 +37,9 @@ function run( return false; })(); - if (hit && img.dataset.imageId) { + if (hit) { // Disallow negative interaction on image which is not visible - window.booru.imagesWithDownvotingDisabled.push(img.dataset.imageId); + window.booru.imagesWithDownvotingDisabled.push(assertNotUndefined(img.dataset.imageId)); } return hit;