Merge pull request #319 from philomena-dev/prettier

Use prettier for JS formatting
This commit is contained in:
liamwhite 2024-07-05 21:57:29 -04:00 committed by GitHub
commit f2d9568db9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 1103 additions and 1040 deletions

14
assets/.prettierrc.yml Normal file
View file

@ -0,0 +1,14 @@
tabWidth: 2
useTabs: false
printWidth: 120
semi: true
singleQuote: true
bracketSpacing: true
endOfLine: lf
quoteProps: as-needed
trailingComma: all
arrowParens: avoid
overrides:
- files: "*.css"
options:
singleQuote: false

View file

@ -1,9 +1,11 @@
import tsEslint from 'typescript-eslint';
import vitestPlugin from 'eslint-plugin-vitest';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
export default tsEslint.config(
...tsEslint.configs.recommended,
eslintPluginPrettierRecommended,
{
name: 'PhilomenaConfig',
files: ['**/*.js', '**/*.ts'],
@ -12,24 +14,22 @@ export default tsEslint.config(
sourceType: 'module',
parserOptions: {
ecmaVersion: 6,
sourceType: 'module'
sourceType: 'module',
},
globals: {
...globals.browser
}
...globals.browser,
},
},
rules: {
'accessor-pairs': 2,
'array-bracket-spacing': 0,
'array-callback-return': 2,
'arrow-body-style': 0,
'arrow-parens': [2, 'as-needed'],
'arrow-spacing': 2,
'block-scoped-var': 2,
'block-spacing': 2,
'brace-style': [2, 'stroustrup', {allowSingleLine: true}],
'callback-return': 0,
camelcase: [2, {allow: ['camo_url', 'spoiler_image_uri', 'image_ids']}],
camelcase: [2, { allow: ['camo_url', 'spoiler_image_uri', 'image_ids'] }],
'class-methods-use-this': 0,
'comma-dangle': [2, 'only-multiline'],
'comma-spacing': 2,
@ -42,7 +42,7 @@ export default tsEslint.config(
curly: [2, 'multi-line', 'consistent'],
'default-case': 2,
'dot-location': [2, 'property'],
'dot-notation': [2, {allowKeywords: true}],
'dot-notation': [2, { allowKeywords: true }],
'eol-last': 2,
eqeqeq: 2,
'func-call-spacing': 0,
@ -56,7 +56,6 @@ export default tsEslint.config(
'id-blacklist': 0,
'id-length': 0,
'id-match': 2,
indent: [2, 2, {SwitchCase: 1, VariableDeclarator: {var: 2, let: 2, const: 3}}],
'init-declarations': 0,
'jsx-quotes': 0,
'key-spacing': 0,
@ -110,7 +109,7 @@ export default tsEslint.config(
'no-extra-bind': 2,
'no-extra-boolean-cast': 2,
'no-extra-label': 2,
'no-extra-parens': [2, 'all', {nestedBinaryExpressions: false}],
'no-extra-parens': [2, 'all', { nestedBinaryExpressions: false }],
'no-extra-semi': 2,
'no-fallthrough': 2,
'no-floating-decimal': 2,
@ -136,7 +135,7 @@ export default tsEslint.config(
'no-mixed-spaces-and-tabs': 2,
'no-multi-spaces': 0,
'no-multi-str': 2,
'no-multiple-empty-lines': [2, {max: 3, maxBOF: 0, maxEOF: 1}],
'no-multiple-empty-lines': [2, { max: 3, maxBOF: 0, maxEOF: 1 }],
'no-native-reassign': 2,
'no-negated-condition': 0,
'no-negated-in-lhs': 2,
@ -190,9 +189,9 @@ export default tsEslint.config(
'no-unreachable': 2,
'no-unsafe-finally': 2,
'no-unsafe-negation': 2,
'no-unused-expressions': [2, {allowShortCircuit: true, allowTernary: true}],
'no-unused-expressions': [2, { allowShortCircuit: true, allowTernary: true }],
'no-unused-labels': 2,
'no-unused-vars': [2, {vars: 'all', args: 'after-used', varsIgnorePattern: '^_', argsIgnorePattern: '^_'}],
'no-unused-vars': [2, { vars: 'all', args: 'after-used', varsIgnorePattern: '^_', argsIgnorePattern: '^_' }],
'no-use-before-define': [2, 'nofunc'],
'no-useless-call': 2,
'no-useless-computed-key': 2,
@ -222,21 +221,19 @@ export default tsEslint.config(
'prefer-spread': 0,
'prefer-template': 2,
'quote-props': [2, 'as-needed'],
quotes: [2, 'single'],
radix: 2,
'require-jsdoc': 0,
'require-yield': 2,
'rest-spread-spacing': 2,
'semi-spacing': [2, {before: false, after: true}],
'semi-spacing': [2, { before: false, after: true }],
semi: 2,
'sort-imports': 0,
'sort-keys': 0,
'sort-vars': 0,
'space-before-blocks': [2, 'always'],
'space-before-function-paren': [2, 'never'],
'space-in-parens': [2, 'never'],
'space-infix-ops': 2,
'space-unary-ops': [2, {words: true, nonwords: false}],
'space-unary-ops': [2, { words: true, nonwords: false }],
'spaced-comment': 0,
strict: [2, 'function'],
'symbol-description': 2,
@ -251,18 +248,15 @@ export default tsEslint.config(
'yield-star-spacing': 2,
yoda: [2, 'never'],
},
ignores: [
'js/vendor/*',
'vite.config.ts'
]
ignores: ['js/vendor/*', 'vite.config.ts'],
},
{
files: ['**/*.js'],
rules: {
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-unused-vars': 'off'
}
'@typescript-eslint/no-unused-vars': 'off',
},
},
{
files: ['**/*.ts'],
@ -271,15 +265,18 @@ export default tsEslint.config(
'no-unused-vars': 'off',
'no-redeclare': 'off',
'no-shadow': 'off',
'@typescript-eslint/no-unused-vars': [2, {vars: 'all', args: 'after-used', varsIgnorePattern: '^_.*', argsIgnorePattern: '^_.*'}],
'@typescript-eslint/no-unused-vars': [
2,
{ vars: 'all', args: 'after-used', varsIgnorePattern: '^_.*', argsIgnorePattern: '^_.*' },
],
'@typescript-eslint/no-redeclare': 2,
'@typescript-eslint/no-shadow': 2
}
'@typescript-eslint/no-shadow': 2,
},
},
{
files: ['**/*.spec.ts', '**/test/*.ts'],
plugins: {
vitest: vitestPlugin
vitest: vitestPlugin,
},
rules: {
...vitestPlugin.configs.recommended.rules,
@ -287,7 +284,7 @@ export default tsEslint.config(
'no-undefined': 'off',
'no-unused-expressions': 0,
'vitest/valid-expect': 0,
'@typescript-eslint/no-unused-expressions': 0
}
}
'@typescript-eslint/no-unused-expressions': 0,
},
},
);

View file

@ -23,11 +23,11 @@ describe('filterNode', () => {
<picture><img src=""/></picture>
</div>
`;
return [ element, assertNotNull($<HTMLDivElement>('.js-spoiler-info-overlay', element)) ];
return [element, assertNotNull($<HTMLDivElement>('.js-spoiler-info-overlay', element))];
}
it('should show image media boxes not matching any filter', () => {
const [ container, spoilerOverlay ] = makeMediaContainer();
const [container, spoilerOverlay] = makeMediaContainer();
filterNode(container);
expect(spoilerOverlay).not.toContainHTML('(Complex Filter)');
@ -36,7 +36,7 @@ describe('filterNode', () => {
});
it('should spoiler media boxes spoilered by a tag filter', () => {
const [ container, spoilerOverlay ] = makeMediaContainer();
const [container, spoilerOverlay] = makeMediaContainer();
window.booru.spoileredTagList = [1];
filterNode(container);
@ -45,7 +45,7 @@ describe('filterNode', () => {
});
it('should spoiler media boxes spoilered by a complex filter', () => {
const [ container, spoilerOverlay ] = makeMediaContainer();
const [container, spoilerOverlay] = makeMediaContainer();
window.booru.spoileredFilter = parseSearch('id:1');
filterNode(container);
@ -54,7 +54,7 @@ describe('filterNode', () => {
});
it('should hide media boxes hidden by a tag filter', () => {
const [ container, spoilerOverlay ] = makeMediaContainer();
const [container, spoilerOverlay] = makeMediaContainer();
window.booru.hiddenTagList = [1];
filterNode(container);
@ -64,7 +64,7 @@ describe('filterNode', () => {
});
it('should hide media boxes hidden by a complex filter', () => {
const [ container, spoilerOverlay ] = makeMediaContainer();
const [container, spoilerOverlay] = makeMediaContainer();
window.booru.hiddenFilter = parseSearch('id:1');
filterNode(container);
@ -90,12 +90,12 @@ describe('filterNode', () => {
element,
assertNotNull($<HTMLDivElement>('.image-filtered', element)),
assertNotNull($<HTMLDivElement>('.image-show', element)),
assertNotNull($<HTMLSpanElement>('.filter-explanation', element))
assertNotNull($<HTMLSpanElement>('.filter-explanation', element)),
];
}
it('should show image blocks not matching any filter', () => {
const [ container, imageFiltered, imageShow ] = makeImageBlock();
const [container, imageFiltered, imageShow] = makeImageBlock();
filterNode(container);
expect(imageFiltered).toHaveClass('hidden');
@ -104,7 +104,7 @@ describe('filterNode', () => {
});
it('should spoiler image blocks spoilered by a tag filter', () => {
const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock();
const [container, imageFiltered, imageShow, filterExplanation] = makeImageBlock();
window.booru.spoileredTagList = [1];
filterNode(container);
@ -116,7 +116,7 @@ describe('filterNode', () => {
});
it('should spoiler image blocks spoilered by a complex filter', () => {
const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock();
const [container, imageFiltered, imageShow, filterExplanation] = makeImageBlock();
window.booru.spoileredFilter = parseSearch('id:1');
filterNode(container);
@ -128,7 +128,7 @@ describe('filterNode', () => {
});
it('should hide image blocks hidden by a tag filter', () => {
const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock();
const [container, imageFiltered, imageShow, filterExplanation] = makeImageBlock();
window.booru.hiddenTagList = [1];
filterNode(container);
@ -140,7 +140,7 @@ describe('filterNode', () => {
});
it('should hide image blocks hidden by a complex filter', () => {
const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock();
const [container, imageFiltered, imageShow, filterExplanation] = makeImageBlock();
window.booru.hiddenFilter = parseSearch('id:1');
filterNode(container);
@ -150,7 +150,6 @@ describe('filterNode', () => {
expect(filterExplanation).toContainHTML('complex tag expression');
expect(window.booru.imagesWithDownvotingDisabled).toContain('1');
});
});
describe('initImagesClientside', () => {

View file

@ -5,18 +5,21 @@ import { fireEvent } from '@testing-library/dom';
describe('Input duplicator functionality', () => {
beforeEach(() => {
document.documentElement.insertAdjacentHTML('beforeend', `<form action="/">
<div class="js-max-input-count">3</div>
<div class="js-input-source">
<input id="0" name="0" class="js-input" type="text"/>
<label>
<a href="#" class="js-remove-input">Delete</a>
</label>
</div>
<div class="js-button-container">
<button type="button" class="js-add-input">Add input</button>
</div>
</form>`);
document.documentElement.insertAdjacentHTML(
'beforeend',
`<form action="/">
<div class="js-max-input-count">3</div>
<div class="js-input-source">
<input id="0" name="0" class="js-input" type="text"/>
<label>
<a href="#" class="js-remove-input">Delete</a>
</label>
</div>
<div class="js-button-container">
<button type="button" class="js-add-input">Add input</button>
</div>
</form>`,
);
});
afterEach(() => {

View file

@ -29,7 +29,7 @@ describe('Remote utilities', () => {
}
describe('a[data-remote]', () => {
const submitA = ({ setMethod }: { setMethod: boolean; }) => {
const submitA = ({ setMethod }: { setMethod: boolean }) => {
const a = document.createElement('a');
a.href = mockEndpoint;
a.dataset.remote = 'remote';
@ -51,8 +51,8 @@ describe('Remote utilities', () => {
credentials: 'same-origin',
headers: {
'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'XMLHttpRequest'
}
'x-requested-with': 'XMLHttpRequest',
},
});
});
@ -64,21 +64,22 @@ describe('Remote utilities', () => {
credentials: 'same-origin',
headers: {
'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'XMLHttpRequest'
}
'x-requested-with': 'XMLHttpRequest',
},
});
});
it('should emit fetchcomplete event', () => new Promise<void>(resolve => {
let a: HTMLAnchorElement | null = null;
it('should emit fetchcomplete event', () =>
new Promise<void>(resolve => {
let a: HTMLAnchorElement | null = null;
addOneShotEventListener('fetchcomplete', event => {
expect(event.target).toBe(a);
resolve();
});
addOneShotEventListener('fetchcomplete', event => {
expect(event.target).toBe(a);
resolve();
});
a = submitA({ setMethod: true });
}));
a = submitA({ setMethod: true });
}));
});
describe('a[data-method]', () => {
@ -93,24 +94,25 @@ describe('Remote utilities', () => {
return a;
};
it('should submit a form with the given action', () => new Promise<void>(resolve => {
addOneShotEventListener('submit', event => {
event.preventDefault();
it('should submit a form with the given action', () =>
new Promise<void>(resolve => {
addOneShotEventListener('submit', event => {
event.preventDefault();
const target = assertType(event.target, HTMLFormElement);
const [ csrf, method ] = target.querySelectorAll('input');
const target = assertType(event.target, HTMLFormElement);
const [csrf, method] = target.querySelectorAll('input');
expect(csrf.name).toBe('_csrf_token');
expect(csrf.value).toBe(window.booru.csrfToken);
expect(csrf.name).toBe('_csrf_token');
expect(csrf.value).toBe(window.booru.csrfToken);
expect(method.name).toBe('_method');
expect(method.value).toBe(mockVerb);
expect(method.name).toBe('_method');
expect(method.value).toBe(mockVerb);
resolve();
});
resolve();
});
submitA();
}));
submitA();
}));
});
describe('form[data-remote]', () => {
@ -167,7 +169,7 @@ describe('Remote utilities', () => {
credentials: 'same-origin',
headers: {
'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'XMLHttpRequest'
'x-requested-with': 'XMLHttpRequest',
},
body: new FormData(),
});
@ -183,25 +185,26 @@ describe('Remote utilities', () => {
credentials: 'same-origin',
headers: {
'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'XMLHttpRequest'
'x-requested-with': 'XMLHttpRequest',
},
body: new FormData(),
});
});
it('should emit fetchcomplete event', () => new Promise<void>(resolve => {
let form: HTMLFormElement | null = null;
it('should emit fetchcomplete event', () =>
new Promise<void>(resolve => {
let form: HTMLFormElement | null = null;
addOneShotEventListener('fetchcomplete', event => {
expect(event.target).toBe(form);
resolve();
});
addOneShotEventListener('fetchcomplete', event => {
expect(event.target).toBe(form);
resolve();
});
form = submitForm();
}));
form = submitForm();
}));
it('should reload the page on 300 multiple choices response', () => {
vi.spyOn(global, 'fetch').mockResolvedValue(new Response('', { status: 300}));
vi.spyOn(global, 'fetch').mockResolvedValue(new Response('', { status: 300 }));
submitForm();
return waitFor(() => expect(window.location.reload).toHaveBeenCalledTimes(1));
@ -267,7 +270,7 @@ describe('Form utilities', () => {
form.insertAdjacentElement('beforeend', button);
document.documentElement.insertAdjacentElement('beforeend', form);
return [ form, button ];
return [form, button];
};
const submitText = 'Submit';
@ -276,7 +279,7 @@ describe('Form utilities', () => {
const loadingMarkup = '<em>Loading...</em>';
it('should disable submit button containing a text child on click', () => {
const [ , button ] = createFormAndButton(submitText, loadingText);
const [, button] = createFormAndButton(submitText, loadingText);
fireEvent.click(button);
expect(button.textContent).toEqual(' Loading...');
@ -284,7 +287,7 @@ describe('Form utilities', () => {
});
it('should disable submit button containing element children on click', () => {
const [ , button ] = createFormAndButton(submitMarkup, loadingMarkup);
const [, button] = createFormAndButton(submitMarkup, loadingMarkup);
fireEvent.click(button);
expect(button.innerHTML).toEqual(loadingMarkup);
@ -292,7 +295,7 @@ describe('Form utilities', () => {
});
it('should not disable anything when the form is invalid', () => {
const [ form, button ] = createFormAndButton(submitText, loadingText);
const [form, button] = createFormAndButton(submitText, loadingText);
form.insertAdjacentHTML('afterbegin', '<input type="text" name="valid" required="true" />');
fireEvent.click(button);
@ -301,7 +304,7 @@ describe('Form utilities', () => {
});
it('should reset submit button containing a text child on completion', () => {
const [ form, button ] = createFormAndButton(submitText, loadingText);
const [form, button] = createFormAndButton(submitText, loadingText);
fireEvent.click(button);
fireEvent(form, new CustomEvent('reset', { bubbles: true }));
@ -310,7 +313,7 @@ describe('Form utilities', () => {
});
it('should reset submit button containing element children on completion', () => {
const [ form, button ] = createFormAndButton(submitMarkup, loadingMarkup);
const [form, button] = createFormAndButton(submitMarkup, loadingMarkup);
fireEvent.click(button);
fireEvent(form, new CustomEvent('reset', { bubbles: true }));
@ -319,7 +322,7 @@ describe('Form utilities', () => {
});
it('should reset disabled form elements on pageshow', () => {
const [ , button ] = createFormAndButton(submitText, loadingText);
const [, button] = createFormAndButton(submitText, loadingText);
fireEvent.click(button);
fireEvent(window, new CustomEvent('pageshow'));

View file

@ -29,12 +29,16 @@ describe('Image upload form', () => {
let mockPng: File;
let mockWebm: File;
beforeAll(async() => {
beforeAll(async () => {
const mockPngPath = join(__dirname, 'upload-test.png');
const mockWebmPath = join(__dirname, 'upload-test.webm');
mockPng = new File([(await promises.readFile(mockPngPath, { encoding: null })).buffer], 'upload-test.png', { type: 'image/png' });
mockWebm = new File([(await promises.readFile(mockWebmPath, { encoding: null })).buffer], 'upload-test.webm', { type: 'video/webm' });
mockPng = new File([(await promises.readFile(mockPngPath, { encoding: null })).buffer], 'upload-test.png', {
type: 'image/png',
});
mockWebm = new File([(await promises.readFile(mockWebmPath, { encoding: null })).buffer], 'upload-test.webm', {
type: 'video/webm',
});
});
beforeAll(() => {
@ -47,7 +51,6 @@ describe('Image upload form', () => {
fixEventListeners(window);
let form: HTMLFormElement;
let imgPreviews: HTMLDivElement;
let fileField: HTMLInputElement;
@ -63,8 +66,9 @@ describe('Image upload form', () => {
};
beforeEach(() => {
document.documentElement.insertAdjacentHTML('beforeend', `
<form action="/images">
document.documentElement.insertAdjacentHTML(
'beforeend',
`<form action="/images">
<div id="js-image-upload-previews"></div>
<input id="image_image" name="image[image]" type="file" class="js-scraper" />
<input id="image_scraper_url" name="image[scraper_url]" type="url" class="js-scraper" />
@ -74,8 +78,8 @@ describe('Image upload form', () => {
<input id="image_sources_0_source" name="image[sources][0][source]" type="text" class="js-source-url" />
<textarea id="image_tag_input" name="image[tag_input]" class="js-image-tags-input"></textarea>
<textarea id="image_description" name="image[description]" class="js-image-descr-input"></textarea>
</form>
`);
</form>`,
);
form = assertNotNull($<HTMLFormElement>('form'));
imgPreviews = assertNotNull($<HTMLDivElement>('#js-image-upload-previews'));
@ -121,7 +125,7 @@ describe('Image upload form', () => {
});
});
it('should block navigation away after an image file is attached, but not after form submission', async() => {
it('should block navigation away after an image file is attached, but not after form submission', async () => {
fireEvent.change(fileField, { target: { files: [mockPng] } });
await waitFor(() => {
assertFetchButtonIsDisabled();
@ -143,7 +147,7 @@ describe('Image upload form', () => {
expect(fireEvent(window, succeededUnloadEvent)).toBe(true);
});
it('should scrape images when the fetch button is clicked', async() => {
it('should scrape images when the fetch button is clicked', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify(scrapeResponse), { status: 200 }));
fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' } });

View file

@ -10,12 +10,12 @@ import store from './utils/store';
const cache = {};
/** @type {HTMLInputElement} */
let inputField,
/** @type {string} */
originalTerm,
/** @type {string} */
originalQuery,
/** @type {TermContext} */
selectedTerm;
/** @type {string} */
originalTerm,
/** @type {string} */
originalQuery,
/** @type {TermContext} */
selectedTerm;
function removeParent() {
const parent = document.querySelector('.autocomplete');
@ -52,15 +52,16 @@ function applySelectedValue(selection) {
}
function changeSelected(firstOrLast, current, sibling) {
if (current && sibling) { // if the currently selected item has a sibling, move selection to it
if (current && sibling) {
// if the currently selected item has a sibling, move selection to it
current.classList.remove('autocomplete__item--selected');
sibling.classList.add('autocomplete__item--selected');
}
else if (current) { // if the next keypress will take the user outside the list, restore the unautocompleted term
} else if (current) {
// if the next keypress will take the user outside the list, restore the unautocompleted term
restoreOriginalValue();
removeSelected();
}
else if (firstOrLast) { // if no item in the list is selected, select the first or last
} else if (firstOrLast) {
// if no item in the list is selected, select the first or last
firstOrLast.classList.add('autocomplete__item--selected');
}
}
@ -74,15 +75,16 @@ function isSelectionOutsideCurrentTerm() {
function keydownHandler(event) {
const selected = document.querySelector('.autocomplete__item--selected'),
firstItem = document.querySelector('.autocomplete__item:first-of-type'),
lastItem = document.querySelector('.autocomplete__item:last-of-type');
firstItem = document.querySelector('.autocomplete__item:first-of-type'),
lastItem = document.querySelector('.autocomplete__item:last-of-type');
if (isSearchField()) {
// Prevent submission of the search field when Enter was hit
if (selected && event.keyCode === 13) event.preventDefault(); // Enter
// Close autocompletion popup when text cursor is outside current tag
if (selectedTerm && firstItem && (event.keyCode === 37 || event.keyCode === 39)) { // ArrowLeft || ArrowRight
if (selectedTerm && firstItem && (event.keyCode === 37 || event.keyCode === 39)) {
// ArrowLeft || ArrowRight
requestAnimationFrame(() => {
if (isSelectionOutsideCurrentTerm()) removeParent();
});
@ -92,7 +94,8 @@ function keydownHandler(event) {
if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousSibling); // ArrowUp
if (event.keyCode === 40) changeSelected(firstItem, selected, selected && selected.nextSibling); // ArrowDown
if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) removeParent(); // Enter || Esc || Comma
if (event.keyCode === 38 || event.keyCode === 40) { // ArrowUp || ArrowDown
if (event.keyCode === 38 || event.keyCode === 40) {
// ArrowUp || ArrowDown
const newSelected = document.querySelector('.autocomplete__item--selected');
if (newSelected) applySelectedValue(newSelected.dataset.value);
event.preventDefault();
@ -123,8 +126,8 @@ function createItem(list, suggestion) {
type: 'click',
label: suggestion.label,
value: suggestion.value,
}
})
},
}),
);
});
@ -133,7 +136,7 @@ function createItem(list, suggestion) {
function createList(suggestions) {
const parent = document.querySelector('.autocomplete'),
list = document.createElement('ul');
list = document.createElement('ul');
list.className = 'autocomplete__list';
suggestions.forEach(suggestion => createItem(list, suggestion));
@ -193,8 +196,7 @@ function toggleSearchAutocomplete() {
for (const searchField of document.querySelectorAll('input[data-ac-mode=search]')) {
if (enable) {
searchField.autocomplete = 'off';
}
else {
} else {
searchField.removeAttribute('data-ac');
searchField.autocomplete = 'on';
}
@ -230,12 +232,13 @@ function listenAutocomplete() {
}
originalTerm = selectedTerm[1].toLowerCase();
}
else {
} else {
originalTerm = `${inputField.value}`.toLowerCase();
}
const suggestions = localAc.topK(originalTerm, suggestionsCount).map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name }));
const suggestions = localAc
.topK(originalTerm, suggestionsCount)
.map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name }));
if (suggestions.length) {
return showAutocomplete(suggestions, originalTerm, event.target);
@ -248,13 +251,12 @@ function listenAutocomplete() {
originalTerm = inputField.value;
const fetchedTerm = inputField.value;
const {ac, acMinLength, acSource} = inputField.dataset;
const { ac, acMinLength, acSource } = inputField.dataset;
if (ac && acSource && (fetchedTerm.length >= acMinLength)) {
if (ac && acSource && fetchedTerm.length >= acMinLength) {
if (cache[fetchedTerm]) {
showAutocomplete(cache[fetchedTerm], fetchedTerm, event.target);
}
else {
} else {
// inputField could get overwritten while the suggestions are being fetched - use event.target
getSuggestions(fetchedTerm).then(suggestions => {
if (fetchedTerm === event.target.value) {
@ -282,7 +284,9 @@ function listenAutocomplete() {
fetch(`/autocomplete/compiled?vsn=2&key=${cacheKey}`, { credentials: 'omit', cache: 'force-cache' })
.then(handleError)
.then(resp => resp.arrayBuffer())
.then(buf => localAc = new LocalAutocompleter(buf));
.then(buf => {
localAc = new LocalAutocompleter(buf);
});
}
}

View file

@ -23,7 +23,7 @@ function persistTag(tagData) {
*/
function isStale(tag) {
const now = new Date().getTime() / 1000;
return tag.fetchedAt === null || tag.fetchedAt < (now - 604800);
return tag.fetchedAt === null || tag.fetchedAt < now - 604800;
}
function clearTags() {
@ -40,11 +40,13 @@ function clearTags() {
*/
function isValidStoredTag(value) {
if (value !== null && '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 (
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;
@ -112,17 +114,18 @@ function verifyTagsVersion(latest) {
}
function initializeFilters() {
const tags = window.booru.spoileredTagList
.concat(window.booru.hiddenTagList)
.filter((a, b, c) => c.indexOf(a) === b);
const tags = window.booru.spoileredTagList.concat(window.booru.hiddenTagList).filter((a, b, c) => c.indexOf(a) === b);
verifyTagsVersion(window.booru.tagsVersion);
fetchNewOrStaleTags(tags);
}
function unmarshal(data) {
try { return JSON.parse(data); }
catch { return data; }
try {
return JSON.parse(data);
} catch {
return data;
}
}
function loadBooruData() {

View file

@ -9,42 +9,44 @@ import { fetchHtml, handleError } from './utils/requests';
import { showBlock } from './utils/image';
import { addTag } from './tagsinput';
/* eslint-disable prettier/prettier */
// Event types and any qualifying conditions - return true to not run action
const types = {
click(event) { return event.button !== 0; /* Left-click only */ },
change() { /* No qualifier */ },
click(event) { return event.button !== 0; /* Left-click only */ },
change() { /* No qualifier */ },
fetchcomplete() { /* No qualifier */ },
};
const actions = {
hide(data) { selectorCb(data.base, data.value, el => el.classList.add('hidden')); },
tabHide(data) { selectorCbChildren(data.base, data.value, el => el.classList.add('hidden')); },
show(data) { selectorCb(data.base, data.value, el => el.classList.remove('hidden')); },
toggle(data) { selectorCb(data.base, data.value, el => el.classList.toggle('hidden')); },
submit(data) { selectorCb(data.base, data.value, el => el.submit()); },
disable(data) { selectorCb(data.base, data.value, el => el.disabled = true); },
hide(data) { selectorCb(data.base, data.value, el => el.classList.add('hidden')); },
show(data) { selectorCb(data.base, data.value, el => el.classList.remove('hidden')); },
toggle(data) { selectorCb(data.base, data.value, el => el.classList.toggle('hidden')); },
submit(data) { selectorCb(data.base, data.value, el => el.submit()); },
disable(data) { selectorCb(data.base, data.value, el => el.disabled = true); },
focus(data) { document.querySelector(data.value).focus(); },
unfilter(data) { showBlock(data.el.closest('.image-show-container')); },
tabHide(data) { selectorCbChildren(data.base, data.value, el => el.classList.add('hidden')); },
preventdefault() { /* The existence of this entry is enough */ },
copy(data) {
document.querySelector(data.value).select();
document.execCommand('copy');
},
inputvalue(data) { document.querySelector(data.value).value = data.el.dataset.setValue; },
inputvalue(data) {
document.querySelector(data.value).value = data.el.dataset.setValue;
},
selectvalue(data) { document.querySelector(data.value).value = data.el.querySelector(':checked').dataset.setValue; },
selectvalue(data) {
document.querySelector(data.value).value = data.el.querySelector(':checked').dataset.setValue;
},
checkall(data) { $$(`${data.value} input[type=checkbox]`).forEach(c => { c.checked = !c.checked; }); },
focus(data) { document.querySelector(data.value).focus(); },
preventdefault() { /* The existence of this entry is enough */ },
checkall(data) {
$$(`${data.value} input[type=checkbox]`).forEach(c => {
c.checked = !c.checked;
});
},
addtag(data) {
addTag(document.querySelector(data.el.closest('[data-target]').dataset.target), data.el.dataset.tagName);
@ -75,13 +77,11 @@ const actions = {
.then(() => newTab.dataset.loaded = true)
.catch(() => newTab.textContent = 'Error!');
}
},
unfilter(data) { showBlock(data.el.closest('.image-show-container')); },
};
/* eslint-enable prettier/prettier */
// Use this function to apply a callback to elements matching the selectors
function selectorCb(base = document, selector, cb) {
[].forEach.call(base.querySelectorAll(selector), cb);
@ -100,16 +100,14 @@ function selectorCbChildren(base = document, selector, cb) {
function matchAttributes(event) {
if (!types[event.type](event)) {
for (const action in actions) {
const attr = `data-${event.type}-${action.toLowerCase()}`,
el = event.target && event.target.closest(`[${attr}]`),
value = el && el.getAttribute(attr);
el = event.target && event.target.closest(`[${attr}]`),
value = el && el.getAttribute(attr);
if (el) {
// Return true if you don't want to preventDefault
actions[action]({ attr, el, value }) || event.preventDefault();
}
}
}
}

View file

@ -59,8 +59,7 @@ export function setupBurgerMenu() {
if (content.classList.contains('open')) {
close(burger, content, body, root);
}
else {
} else {
open(burger, content, body, root);
}
});

View file

@ -5,8 +5,8 @@ import { clearEl, makeEl } from './utils/dom';
function insertCaptcha(_event: Event, target: HTMLInputElement) {
const parentElement = assertNotNull(target.parentElement);
const script = makeEl('script', {src: 'https://hcaptcha.com/1/api.js', async: true, defer: true});
const frame = makeEl('div', {className: 'h-captcha'});
const script = makeEl('script', { src: 'https://hcaptcha.com/1/api.js', async: true, defer: true });
const frame = makeEl('div', { className: 'h-captcha' });
frame.dataset.sitekey = target.dataset.sitekey;
@ -17,5 +17,5 @@ function insertCaptcha(_event: Event, target: HTMLInputElement) {
}
export function bindCaptchaLinks() {
delegate(document, 'click', {'.js-captcha': leftClick(insertCaptcha)});
delegate(document, 'click', { '.js-captcha': leftClick(insertCaptcha) });
}

View file

@ -8,22 +8,19 @@ import { fetchHtml } from './utils/requests';
import { timeAgo } from './timeago';
function handleError(response) {
const errorMessage = '<div>Comment failed to load!</div>';
if (!response.ok) {
return errorMessage;
}
return response.text();
}
function commentPosted(response) {
const commentEditTab = $('#js-comment-form a[data-click-tab="write"]'),
commentEditForm = $('#js-comment-form'),
container = document.getElementById('comments'),
requestOk = response.ok;
const commentEditTab = $('#js-comment-form a[data-click-tab="write"]'),
commentEditForm = $('#js-comment-form'),
container = document.getElementById('comments'),
requestOk = response.ok;
commentEditTab.click();
commentEditForm.reset();
@ -32,26 +29,22 @@ function commentPosted(response) {
response.text().then(text => {
if (text.includes('<div class="flash flash--warning">')) {
window.location.reload();
}
else {
} else {
displayComments(container, text);
}
});
}
else {
} else {
window.location.reload();
window.scrollTo(0, 0); // Error message is displayed at the top of the page (flash)
}
}
function loadParentPost(event) {
const clickedLink = event.target,
// Find the comment containing the link that was clicked
fullComment = clickedLink.closest('article.block'),
// Look for a potential image and comment ID
commentMatches = /(\w+)#comment_(\w+)$/.exec(clickedLink.getAttribute('href'));
// Find the comment containing the link that was clicked
fullComment = clickedLink.closest('article.block'),
// Look for a potential image and comment ID
commentMatches = /(\w+)#comment_(\w+)$/.exec(clickedLink.getAttribute('href'));
// If the clicked link is already active, just clear the parent comments
if (clickedLink.classList.contains('active_reply_link')) {
@ -61,9 +54,8 @@ function loadParentPost(event) {
}
if (commentMatches) {
// If the regex matched, get the image and comment ID
const [ , imageId, commentId ] = commentMatches;
const [, imageId, commentId] = commentMatches;
fetchHtml(`/images/${imageId}/comments/${commentId}`)
.then(handleError)
@ -73,13 +65,10 @@ function loadParentPost(event) {
});
return true;
}
}
function insertParentPost(data, clickedLink, fullComment) {
// Add the 'subthread' class to the comment with the clicked link
fullComment.classList.add('subthread');
@ -98,11 +87,9 @@ function insertParentPost(data, clickedLink, fullComment) {
// Filter images (if any) in the loaded comment
filterNode(fullComment.previousSibling);
}
function clearParentPost(clickedLink, fullComment) {
// Remove any previous siblings with the class fetched-comment
while (fullComment.previousSibling && fullComment.previousSibling.classList.contains('fetched-comment')) {
fullComment.previousSibling.parentNode.removeChild(fullComment.previousSibling);
@ -117,11 +104,9 @@ function clearParentPost(clickedLink, fullComment) {
if (!fullComment.classList.contains('fetched-comment')) {
fullComment.classList.remove('subthread');
}
}
function displayComments(container, commentsHtml) {
container.innerHTML = commentsHtml;
// Execute timeago on comments
@ -129,21 +114,21 @@ function displayComments(container, commentsHtml) {
// Filter images in the comments
filterNode(container);
}
function loadComments(event) {
const container = document.getElementById('comments'),
hasHref = event.target && event.target.getAttribute('href'),
hasHash = window.location.hash && window.location.hash.match(/#comment_([a-f0-9]+)/),
getURL = hasHref || (hasHash ? `${container.dataset.currentUrl}?comment_id=${window.location.hash.substring(9, window.location.hash.length)}`
: container.dataset.currentUrl);
hasHref = event.target && event.target.getAttribute('href'),
hasHash = window.location.hash && window.location.hash.match(/#comment_([a-f0-9]+)/),
getURL =
hasHref ||
(hasHash
? `${container.dataset.currentUrl}?comment_id=${window.location.hash.substring(9, window.location.hash.length)}`
: container.dataset.currentUrl);
fetchHtml(getURL)
.then(handleError)
.then(data => {
displayComments(container, data);
// Make sure the :target CSS selector applies to the inserted content
@ -155,21 +140,19 @@ function loadComments(event) {
});
return true;
}
function setupComments() {
const comments = document.getElementById('comments'),
hasHash = window.location.hash && window.location.hash.match(/^#comment_([a-f0-9]+)$/),
targetOnPage = hasHash ? Boolean($(window.location.hash)) : true;
const comments = document.getElementById('comments'),
hasHash = window.location.hash && window.location.hash.match(/^#comment_([a-f0-9]+)$/),
targetOnPage = hasHash ? Boolean($(window.location.hash)) : true;
// Load comments over AJAX if we are on a page with element #comments
if (comments) {
if (!comments.dataset.loaded || !targetOnPage) {
// There is no event associated with the initial load, so use false
loadComments(false);
}
else {
} else {
filterNode(comments);
}
}
@ -182,7 +165,8 @@ function setupComments() {
};
document.addEventListener('click', event => {
if (event.button === 0) { // Left-click only
if (event.button === 0) {
// Left-click only
for (const target in targets) {
if (event.target && event.target.closest(target)) {
targets[target](event) && event.preventDefault();

View file

@ -15,7 +15,7 @@ export function setupDupeReports() {
}
function setupSwipe(swipe: SVGSVGElement) {
const [ clip, divider ] = $$<SVGRectElement>('#clip rect, #divider', swipe);
const [clip, divider] = $$<SVGRectElement>('#clip rect, #divider', swipe);
const { width } = swipe.viewBox.baseVal;
function moveDivider({ clientX }: MouseEvent) {

View file

@ -11,19 +11,19 @@ const storageKey = 'cached_ses_value';
declare global {
interface Keyboard {
getLayoutMap: () => Promise<Map<string, string>>
getLayoutMap: () => Promise<Map<string, string>>;
}
interface UserAgentData {
brands: [{brand: string, version: string}],
mobile: boolean,
platform: string,
brands: [{ brand: string; version: string }];
mobile: boolean;
platform: string;
}
interface Navigator {
deviceMemory: number | undefined,
keyboard: Keyboard | undefined,
userAgentData: UserAgentData | undefined,
deviceMemory: number | undefined;
keyboard: Keyboard | undefined;
userAgentData: UserAgentData | undefined;
}
}
@ -45,10 +45,10 @@ function cyrb53(str: string, seed: number = 0x16fe7b0a): number {
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507);
h1 ^= Math.imul(h2 ^ h2 >>> 13, 3266489909);
h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507);
h2 ^= Math.imul(h1 ^ h1 >>> 13, 3266489909);
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
}
@ -161,9 +161,7 @@ async function createFp(): Promise<string> {
new Date().getTimezoneOffset().toString(),
];
return cyrb53(prints.join(''))
.toString(16)
.padStart(14, '0');
return cyrb53(prints.join('')).toString(16).padStart(14, '0');
}
/**

View file

@ -11,7 +11,7 @@ import { fetchJson } from './utils/requests';
export function setupGalleryEditing() {
if (!$('.rearrange-button')) return;
const [ rearrangeEl, saveEl ] = $$<HTMLElement>('.rearrange-button');
const [rearrangeEl, saveEl] = $$<HTMLElement>('.rearrange-button');
const sortableEl = assertNotNull($<HTMLDivElement>('#sortable'));
const containerEl = assertNotNull($<HTMLDivElement>('.js-resizable-media-container'));
@ -22,7 +22,9 @@ export function setupGalleryEditing() {
initDraggables();
$$<HTMLDivElement>('.media-box', containerEl).forEach(i => i.draggable = true);
for (const mediaBox of $$<HTMLDivElement>('.media-box', containerEl)) {
mediaBox.draggable = true;
}
rearrangeEl.addEventListener('click', () => {
sortableEl.classList.add('editing');
@ -33,8 +35,9 @@ export function setupGalleryEditing() {
sortableEl.classList.remove('editing');
containerEl.classList.remove('drag-container');
newImages = $$<HTMLDivElement>('.image-container', containerEl)
.map(i => parseInt(assertNotUndefined(i.dataset.imageId), 10));
newImages = $$<HTMLDivElement>('.image-container', containerEl).map(i =>
parseInt(assertNotUndefined(i.dataset.imageId), 10),
);
// If nothing changed, don't bother.
if (arraysEqual(newImages, oldImages)) return;
@ -43,8 +46,9 @@ export function setupGalleryEditing() {
fetchJson('PATCH', reorderPath, {
image_ids: newImages,
// copy the array again so that we have the newly updated set
}).then(() => oldImages = newImages.slice());
}).then(() => {
// copy the array again so that we have the newly updated set
oldImages = newImages.slice();
});
});
}

View file

@ -5,7 +5,7 @@ const imageVersions = {
// [width, height]
small: [320, 240],
medium: [800, 600],
large: [1280, 1024]
large: [1280, 1024],
};
/**
@ -14,7 +14,7 @@ const imageVersions = {
*/
function selectVersion(imageWidth, imageHeight, imageSize, imageMime) {
let viewWidth = document.documentElement.clientWidth,
viewHeight = document.documentElement.clientHeight;
viewHeight = document.documentElement.clientHeight;
// load hires if that's what you asked for
if (store.get('serve_hidpi')) {
@ -31,9 +31,9 @@ function selectVersion(imageWidth, imageHeight, imageSize, imageMime) {
// .find() is not supported in older browsers, using a loop
for (let i = 0, versions = Object.keys(imageVersions); i < versions.length; ++i) {
const version = versions[i],
dimensions = imageVersions[version],
versionWidth = Math.min(imageWidth, dimensions[0]),
versionHeight = Math.min(imageHeight, dimensions[1]);
dimensions = imageVersions[version],
versionWidth = Math.min(imageWidth, dimensions[0]),
versionHeight = Math.min(imageHeight, dimensions[1]);
if (versionWidth > viewWidth || versionHeight > viewHeight) {
return version;
}
@ -57,11 +57,11 @@ function selectVersion(imageWidth, imageHeight, imageSize, imageMime) {
*/
function pickAndResize(elem) {
const imageWidth = parseInt(elem.dataset.width, 10),
imageHeight = parseInt(elem.dataset.height, 10),
imageSize = parseInt(elem.dataset.imageSize, 10),
imageMime = elem.dataset.mimeType,
scaled = elem.dataset.scaled,
uris = JSON.parse(elem.dataset.uris);
imageHeight = parseInt(elem.dataset.height, 10),
imageSize = parseInt(elem.dataset.imageSize, 10),
imageMime = elem.dataset.mimeType,
scaled = elem.dataset.scaled,
uris = JSON.parse(elem.dataset.uris);
let version = 'full';
@ -91,7 +91,8 @@ function pickAndResize(elem) {
if (imageFormat === 'mp4') {
elem.classList.add('full-height');
elem.insertAdjacentHTML('afterbegin',
elem.insertAdjacentHTML(
'afterbegin',
`<video controls ${autoplay} loop ${muted} playsinline preload="auto" id="image-display"
width="${imageWidth}" height="${imageHeight}">
<source src="${uris.webm}" type="video/webm">
@ -100,11 +101,11 @@ function pickAndResize(elem) {
Your browser supports neither MP4/H264 nor
WebM/VP8! Please update it to the latest version.
</p>
</video>`
</video>`,
);
}
else if (imageFormat === 'webm') {
elem.insertAdjacentHTML('afterbegin',
} else if (imageFormat === 'webm') {
elem.insertAdjacentHTML(
'afterbegin',
`<video controls ${autoplay} loop ${muted} playsinline id="image-display">
<source src="${uri}" type="video/webm">
<source src="${uri.replace(/webm$/, 'mp4')}" type="video/mp4">
@ -112,25 +113,21 @@ function pickAndResize(elem) {
Your browser supports neither MP4/H264 nor
WebM/VP8! Please update it to the latest version.
</p>
</video>`
</video>`,
);
const video = elem.querySelector('video');
if (scaled === 'true') {
video.className = 'image-scaled';
}
else if (scaled === 'partscaled') {
} else if (scaled === 'partscaled') {
video.className = 'image-partscaled';
}
}
else {
} else {
let image;
if (scaled === 'true') {
image = `<picture><img id="image-display" src="${uri}" class="image-scaled"></picture>`;
}
else if (scaled === 'partscaled') {
} else if (scaled === 'partscaled') {
image = `<picture><img id="image-display" src="${uri}" class="image-partscaled"></picture>`;
}
else {
} else {
image = `<picture><img id="image-display" src="${uri}" width="${imageWidth}" height="${imageHeight}"></picture>`;
}
if (elem.innerHTML === image) return;
@ -148,11 +145,9 @@ function bindImageForClick(target) {
target.addEventListener('click', () => {
if (target.getAttribute('data-scaled') === 'true') {
target.setAttribute('data-scaled', 'partscaled');
}
else if (target.getAttribute('data-scaled') === 'partscaled') {
} else if (target.getAttribute('data-scaled') === 'partscaled') {
target.setAttribute('data-scaled', 'false');
}
else {
} else {
target.setAttribute('data-scaled', 'true');
}

View file

@ -12,12 +12,7 @@ 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,
runCallback: RunCallback
): boolean {
function run(img: HTMLDivElement, tags: TagData[], complex: AstMatcher, runCallback: RunCallback): boolean {
const hit = (() => {
// Check tags array first to provide more precise filter explanations
const hitTags = imageHitsTags(img, tags);
@ -56,47 +51,46 @@ function bannerImage(tagsHit: TagData[]) {
// 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] <i>(Complex Filter)</i>';
const bannerText = type === 'tags' ? `[HIDDEN] ${displayTags(tagsHit)}` : '[HIDDEN] <i>(Complex Filter)</i>';
hideThumb(img, bannerImage(tagsHit), bannerText);
}
function spoilerThumbTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) {
const bannerText = type === 'tags' ? displayTags(tagsHit)
: '<i>(Complex Filter)</i>';
const bannerText = type === 'tags' ? displayTags(tagsHit) : '<i>(Complex Filter)</i>';
spoilerThumb(img, bannerImage(tagsHit), bannerText);
}
function hideBlockTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) {
const bannerText = type === 'tags' ? `This image is tagged <code>${escapeHtml(tagsHit[0].name)}</code>, which is hidden by `
: 'This image was hidden by a complex tag expression in ';
const bannerText =
type === 'tags'
? `This image is tagged <code>${escapeHtml(tagsHit[0].name)}</code>, 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 <code>${escapeHtml(tagsHit[0].name)}</code>, which is spoilered by `
: 'This image was spoilered by a complex tag expression in ';
const bannerText =
type === 'tags'
? `This image is tagged <code>${escapeHtml(tagsHit[0].name)}</code>, 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<Document, 'querySelectorAll'>) {
const hiddenTags = getHiddenTags(), spoileredTags = getSpoileredTags();
const hiddenTags = getHiddenTags(),
spoileredTags = getSpoileredTags();
const { hiddenFilter, spoileredFilter } = window.booru;
// Image thumb boxes with vote and fave buttons on them
$$<HTMLDivElement>('.image-container', node)
.filter(img => !run(img, hiddenTags, hiddenFilter, hideThumbTyped))
.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
$$<HTMLDivElement>('.image-show-container', node)
.filter(img => !run(img, hiddenTags, hiddenFilter, hideBlockTyped))
.filter(img => !run(img, hiddenTags, hiddenFilter, hideBlockTyped))
.filter(img => !run(img, spoileredTags, spoileredFilter, spoilerBlockTyped))
.forEach(img => showBlock(img));
}

View file

@ -13,7 +13,7 @@ export function inputDuplicatorCreator({
addButtonSelector,
fieldSelector,
maxInputCountSelector,
removeButtonSelector
removeButtonSelector,
}: InputDuplicatorOptions) {
const addButton = $<HTMLButtonElement>(addButtonSelector);
if (!addButton) {
@ -35,10 +35,9 @@ export function inputDuplicatorCreator({
};
delegate(form, 'click', {
[removeButtonSelector]: leftClick(fieldRemover)
[removeButtonSelector]: leftClick(fieldRemover),
});
const maxOptionCountElement = assertNotNull($(maxInputCountSelector, form));
const maxOptionCount = parseInt(maxOptionCountElement.innerHTML, 10);

View file

@ -6,18 +6,22 @@ import { fetchJson } from './utils/requests';
import { $ } from './utils/dom';
const endpoints = {
vote(imageId) { return `/images/${imageId}/vote`; },
fave(imageId) { return `/images/${imageId}/fave`; },
hide(imageId) { return `/images/${imageId}/hide`; },
vote(imageId) {
return `/images/${imageId}/vote`;
},
fave(imageId) {
return `/images/${imageId}/fave`;
},
hide(imageId) {
return `/images/${imageId}/hide`;
},
};
const spoilerDownvoteMsg =
'Neigh! - Remove spoilered tags from your filters to downvote from thumbnails';
const spoilerDownvoteMsg = 'Neigh! - Remove spoilered tags from your filters to downvote from thumbnails';
/* Quick helper function to less verbosely iterate a QSA */
function onImage(id, selector, cb) {
[].forEach.call(
document.querySelectorAll(`${selector}[data-image-id="${id}"]`), cb);
[].forEach.call(document.querySelectorAll(`${selector}[data-image-id="${id}"]`), cb);
}
/* Since JS modifications to webpages, except form inputs, are not stored
@ -49,14 +53,18 @@ function uncacheStatus(imageId, interactionType) {
}
function setScore(imageId, data) {
onImage(imageId, '.score',
el => el.textContent = data.score);
onImage(imageId, '.favorites',
el => el.textContent = data.faves);
onImage(imageId, '.upvotes',
el => el.textContent = data.upvotes);
onImage(imageId, '.downvotes',
el => el.textContent = data.downvotes);
onImage(imageId, '.score', el => {
el.textContent = data.score;
});
onImage(imageId, '.favorites', el => {
el.textContent = data.faves;
});
onImage(imageId, '.upvotes', el => {
el.textContent = data.upvotes;
});
onImage(imageId, '.downvotes', el => {
el.textContent = data.downvotes;
});
}
/* These change the visual appearance of interaction links.
@ -64,48 +72,38 @@ function setScore(imageId, data) {
function showUpvoted(imageId) {
cacheStatus(imageId, 'voted', 'up');
onImage(imageId, '.interaction--upvote',
el => el.classList.add('active'));
onImage(imageId, '.interaction--upvote', el => el.classList.add('active'));
}
function showDownvoted(imageId) {
cacheStatus(imageId, 'voted', 'down');
onImage(imageId, '.interaction--downvote',
el => el.classList.add('active'));
onImage(imageId, '.interaction--downvote', el => el.classList.add('active'));
}
function showFaved(imageId) {
cacheStatus(imageId, 'faved', '');
onImage(imageId, '.interaction--fave',
el => el.classList.add('active'));
onImage(imageId, '.interaction--fave', el => el.classList.add('active'));
}
function showHidden(imageId) {
cacheStatus(imageId, 'hidden', '');
onImage(imageId, '.interaction--hide',
el => el.classList.add('active'));
onImage(imageId, '.interaction--hide', el => el.classList.add('active'));
}
function resetVoted(imageId) {
uncacheStatus(imageId, 'voted');
onImage(imageId, '.interaction--upvote',
el => el.classList.remove('active'));
onImage(imageId, '.interaction--downvote',
el => el.classList.remove('active'));
onImage(imageId, '.interaction--upvote', el => el.classList.remove('active'));
onImage(imageId, '.interaction--downvote', el => el.classList.remove('active'));
}
function resetFaved(imageId) {
uncacheStatus(imageId, 'faved');
onImage(imageId, '.interaction--fave',
el => el.classList.remove('active'));
onImage(imageId, '.interaction--fave', el => el.classList.remove('active'));
}
function resetHidden(imageId) {
uncacheStatus(imageId, 'hidden');
onImage(imageId, '.interaction--hide',
el => el.classList.remove('active'));
onImage(imageId, '.interaction--hide', el => el.classList.remove('active'));
}
function interact(type, imageId, method, data = {}) {
@ -131,7 +129,6 @@ function displayInteractionSet(interactions) {
}
function loadInteractions() {
/* Set up the actual interactions */
displayInteractionSet(window.booru.interactions);
displayInteractionSet(getCache());
@ -143,66 +140,69 @@ function loadInteractions() {
/* Users will blind downvote without this */
window.booru.imagesWithDownvotingDisabled.forEach(i => {
onImage(i, '.interaction--downvote', a => {
// TODO Use a 'js-' class to target these instead
const icon = a.querySelector('i') || a.querySelector('.oc-icon-small');
icon.setAttribute('title', spoilerDownvoteMsg);
a.classList.add('disabled');
a.addEventListener('click', event => {
event.stopPropagation();
event.preventDefault();
}, true);
a.addEventListener(
'click',
event => {
event.stopPropagation();
event.preventDefault();
},
true,
);
});
});
}
const targets = {
/* Active-state targets first */
'.interaction--upvote.active'(imageId) {
interact('vote', imageId, 'DELETE')
.then(() => resetVoted(imageId));
interact('vote', imageId, 'DELETE').then(() => resetVoted(imageId));
},
'.interaction--downvote.active'(imageId) {
interact('vote', imageId, 'DELETE')
.then(() => resetVoted(imageId));
interact('vote', imageId, 'DELETE').then(() => resetVoted(imageId));
},
'.interaction--fave.active'(imageId) {
interact('fave', imageId, 'DELETE')
.then(() => resetFaved(imageId));
interact('fave', imageId, 'DELETE').then(() => resetFaved(imageId));
},
'.interaction--hide.active'(imageId) {
interact('hide', imageId, 'DELETE')
.then(() => resetHidden(imageId));
interact('hide', imageId, 'DELETE').then(() => resetHidden(imageId));
},
/* Inactive targets */
'.interaction--upvote:not(.active)'(imageId) {
interact('vote', imageId, 'POST', { up: true })
.then(() => { resetVoted(imageId); showUpvoted(imageId); });
interact('vote', imageId, 'POST', { up: true }).then(() => {
resetVoted(imageId);
showUpvoted(imageId);
});
},
'.interaction--downvote:not(.active)'(imageId) {
interact('vote', imageId, 'POST', { up: false })
.then(() => { resetVoted(imageId); showDownvoted(imageId); });
interact('vote', imageId, 'POST', { up: false }).then(() => {
resetVoted(imageId);
showDownvoted(imageId);
});
},
'.interaction--fave:not(.active)'(imageId) {
interact('fave', imageId, 'POST')
.then(() => { resetVoted(imageId); showFaved(imageId); showUpvoted(imageId); });
interact('fave', imageId, 'POST').then(() => {
resetVoted(imageId);
showFaved(imageId);
showUpvoted(imageId);
});
},
'.interaction--hide:not(.active)'(imageId) {
interact('hide', imageId, 'POST')
.then(() => { showHidden(imageId); });
interact('hide', imageId, 'POST').then(() => {
showHidden(imageId);
});
},
};
function bindInteractions() {
document.addEventListener('click', event => {
if (event.button === 0) { // Is it a left-click?
if (event.button === 0) {
// Is it a left-click?
for (const target in targets) {
/* Event delegation doesn't quite grab what we want here. */
const link = event.target && event.target.closest(target);
@ -213,21 +213,20 @@ function bindInteractions() {
}
}
}
});
}
function loggedOutInteractions() {
[].forEach.call(document.querySelectorAll('.interaction--fave,.interaction--upvote,.interaction--downvote'),
a => a.setAttribute('href', '/sessions/new'));
[].forEach.call(document.querySelectorAll('.interaction--fave,.interaction--upvote,.interaction--downvote'), a =>
a.setAttribute('href', '/sessions/new'),
);
}
function setupInteractions() {
if (window.booru.userIsSignedIn) {
bindInteractions();
loadInteractions();
}
else {
} else {
loggedOutInteractions();
}
}

View file

@ -7,19 +7,19 @@ import { $, $$ } from './utils/dom';
const markdownSyntax = {
bold: {
action: wrapSelection,
options: { prefix: '**', shortcutKey: 'b' }
options: { prefix: '**', shortcutKey: 'b' },
},
italics: {
action: wrapSelection,
options: { prefix: '*', shortcutKey: 'i' }
options: { prefix: '*', shortcutKey: 'i' },
},
under: {
action: wrapSelection,
options: { prefix: '__', shortcutKey: 'u' }
options: { prefix: '__', shortcutKey: 'u' },
},
spoiler: {
action: wrapSelection,
options: { prefix: '||', shortcutKey: 's' }
options: { prefix: '||', shortcutKey: 's' },
},
code: {
action: wrapSelectionOrLines,
@ -29,57 +29,56 @@ const markdownSyntax = {
prefixMultiline: '```\n',
suffixMultiline: '\n```',
singleWrap: true,
shortcutKey: 'e'
}
shortcutKey: 'e',
},
},
strike: {
action: wrapSelection,
options: { prefix: '~~' }
options: { prefix: '~~' },
},
superscript: {
action: wrapSelection,
options: { prefix: '^' }
options: { prefix: '^' },
},
subscript: {
action: wrapSelection,
options: { prefix: '%' }
options: { prefix: '%' },
},
quote: {
action: wrapLines,
options: { prefix: '> ' }
options: { prefix: '> ' },
},
link: {
action: insertLink,
options: { shortcutKey: 'l' }
options: { shortcutKey: 'l' },
},
image: {
action: insertLink,
options: { image: true, shortcutKey: 'k' }
options: { image: true, shortcutKey: 'k' },
},
escape: {
action: escapeSelection,
options: { escapeChar: '\\' }
}
options: { escapeChar: '\\' },
},
};
function getSelections(textarea, linesOnly = false) {
let { selectionStart, selectionEnd } = textarea,
selection = textarea.value.substring(selectionStart, selectionEnd),
leadingSpace = '',
trailingSpace = '',
caret;
selection = textarea.value.substring(selectionStart, selectionEnd),
leadingSpace = '',
trailingSpace = '',
caret;
const processLinesOnly = linesOnly instanceof RegExp ? linesOnly.test(selection) : linesOnly;
if (processLinesOnly) {
const explorer = /\n/g;
let startNewlineIndex = 0,
endNewlineIndex = textarea.value.length;
endNewlineIndex = textarea.value.length;
while (explorer.exec(textarea.value)) {
const { lastIndex } = explorer;
if (lastIndex <= selectionStart) {
startNewlineIndex = lastIndex;
}
else if (lastIndex > selectionEnd) {
} else if (lastIndex > selectionEnd) {
endNewlineIndex = lastIndex - 1;
break;
}
@ -96,8 +95,7 @@ function getSelections(textarea, linesOnly = false) {
}
selectionEnd = endNewlineIndex;
selection = textarea.value.substring(selectionStart, selectionEnd);
}
else {
} else {
// Deselect trailing space and line break
for (caret = selection.length - 1; caret > 0; caret--) {
if (selection[caret] !== ' ' && selection[caret] !== '\n') break;
@ -117,22 +115,23 @@ function getSelections(textarea, linesOnly = false) {
processLinesOnly,
selectedText: selection,
beforeSelection: textarea.value.substring(0, selectionStart) + leadingSpace,
afterSelection: trailingSpace + textarea.value.substring(selectionEnd)
afterSelection: trailingSpace + textarea.value.substring(selectionEnd),
};
}
function transformSelection(textarea, transformer, eachLine) {
const { selectedText, beforeSelection, afterSelection, processLinesOnly } = getSelections(textarea, eachLine),
// For long comments, record scrollbar position to restore it later
{ scrollTop } = textarea;
// For long comments, record scrollbar position to restore it later
{ scrollTop } = textarea;
const { newText, caretOffset } = transformer(selectedText, processLinesOnly);
textarea.value = beforeSelection + newText + afterSelection;
const newSelectionStart = caretOffset >= 1
? beforeSelection.length + caretOffset
: textarea.value.length - afterSelection.length - caretOffset;
const newSelectionStart =
caretOffset >= 1
? beforeSelection.length + caretOffset
: textarea.value.length - afterSelection.length - caretOffset;
textarea.selectionStart = newSelectionStart;
textarea.selectionEnd = newSelectionStart;
@ -151,7 +150,7 @@ function insertLink(textarea, options) {
}
const prefix = options.image ? '![' : '[',
suffix = `](${hyperlink})`;
suffix = `](${hyperlink})`;
wrapSelection(textarea, { prefix, suffix });
}
@ -159,7 +158,7 @@ function insertLink(textarea, options) {
function wrapSelection(textarea, options) {
transformSelection(textarea, selectedText => {
const { text = selectedText, prefix = '', suffix = options.prefix } = options,
emptyText = text === '';
emptyText = text === '';
let newText = text;
if (!emptyText) {
@ -172,26 +171,33 @@ function wrapSelection(textarea, options) {
return {
newText,
caretOffset: emptyText ? prefix.length : newText.length
caretOffset: emptyText ? prefix.length : newText.length,
};
});
}
function wrapLines(textarea, options, eachLine = true) {
transformSelection(textarea, (selectedText, processLinesOnly) => {
const { text = selectedText, singleWrap = false } = options,
prefix = (processLinesOnly && options.prefixMultiline) || options.prefix || '',
suffix = (processLinesOnly && options.suffixMultiline) || options.suffix || '',
emptyText = text === '';
let newText = singleWrap
? prefix + text.trim() + suffix
: text.split(/\n/g).map(line => prefix + line.trim() + suffix).join('\n');
transformSelection(
textarea,
(selectedText, processLinesOnly) => {
const { text = selectedText, singleWrap = false } = options,
prefix = (processLinesOnly && options.prefixMultiline) || options.prefix || '',
suffix = (processLinesOnly && options.suffixMultiline) || options.suffix || '',
emptyText = text === '';
let newText = singleWrap
? prefix + text.trim() + suffix
: text
.split(/\n/g)
.map(line => prefix + line.trim() + suffix)
.join('\n');
// Force a space at the end of lines with only blockquote markers
newText = newText.replace(/^((?:>\s+)*)>$/gm, '$1> ');
// Force a space at the end of lines with only blockquote markers
newText = newText.replace(/^((?:>\s+)*)>$/gm, '$1> ');
return { newText, caretOffset: emptyText ? prefix.length : newText.length };
}, eachLine);
return { newText, caretOffset: emptyText ? prefix.length : newText.length };
},
eachLine,
);
}
function wrapSelectionOrLines(textarea, options) {
@ -201,7 +207,7 @@ function wrapSelectionOrLines(textarea, options) {
function escapeSelection(textarea, options) {
transformSelection(textarea, selectedText => {
const { text = selectedText } = options,
emptyText = text === '';
emptyText = text === '';
if (emptyText) return;
@ -209,7 +215,7 @@ function escapeSelection(textarea, options) {
return {
newText,
caretOffset: newText.length
caretOffset: newText.length,
};
});
}
@ -218,20 +224,40 @@ function clickHandler(event) {
const button = event.target.closest('.communication__toolbar__button');
if (!button) return;
const toolbar = button.closest('.communication__toolbar'),
// There may be multiple toolbars present on the page,
// in the case of image pages with description edit active
// we target the textarea that shares the same parent as the toolbar
textarea = $('.js-toolbar-input', toolbar.parentNode),
id = button.dataset.syntaxId;
// There may be multiple toolbars present on the page,
// in the case of image pages with description edit active
// we target the textarea that shares the same parent as the toolbar
textarea = $('.js-toolbar-input', toolbar.parentNode),
id = button.dataset.syntaxId;
markdownSyntax[id].action(textarea, markdownSyntax[id].options);
textarea.focus();
}
function canAcceptShortcut(event) {
let ctrl, otherModifier;
switch (window.navigator.platform) {
case 'MacIntel':
ctrl = event.metaKey;
otherModifier = event.ctrlKey || event.shiftKey || event.altKey;
break;
default:
ctrl = event.ctrlKey;
otherModifier = event.metaKey || event.shiftKey || event.altKey;
break;
}
return ctrl && !otherModifier;
}
function shortcutHandler(event) {
if (!event.ctrlKey || (window.navigator.platform === 'MacIntel' && !event.metaKey) || event.shiftKey || event.altKey) return;
if (!canAcceptShortcut(event)) {
return;
}
const textarea = event.target,
key = event.key.toLowerCase();
key = event.key.toLowerCase();
for (const id in markdownSyntax) {
if (key === markdownSyntax[id].options.shortcutKey) {

View file

@ -9,10 +9,10 @@ import '../types/ujs';
let touchMoved = false;
function formResult({target, detail}: FetchcompleteEvent) {
function formResult({ target, detail }: FetchcompleteEvent) {
const elements: Record<string, string> = {
'#description-form': '.image-description',
'#uploader-form': '.image_uploader'
'#uploader-form': '.image_uploader',
};
function showResult(formEl: HTMLFormElement, resultEl: HTMLElement, response: string) {
@ -25,7 +25,7 @@ function formResult({target, detail}: FetchcompleteEvent) {
});
}
for (const [ formSelector, resultSelector ] of Object.entries(elements)) {
for (const [formSelector, resultSelector] of Object.entries(elements)) {
if (target.matches(formSelector)) {
const form = assertType(target, HTMLFormElement);
const result = assertNotNull($<HTMLElement>(resultSelector));
@ -91,5 +91,7 @@ export function setupEvents() {
document.addEventListener('fetchcomplete', formResult);
document.addEventListener('click', revealSpoiler);
document.addEventListener('touchend', revealSpoiler);
document.addEventListener('touchmove', () => touchMoved = true);
document.addEventListener('touchmove', () => {
touchMoved = true;
});
}

View file

@ -8,8 +8,8 @@ import { delegate } from './utils/events';
import { assertNotNull, assertNotUndefined } from './utils/assert';
import store from './utils/store';
const NOTIFICATION_INTERVAL = 600000,
NOTIFICATION_EXPIRES = 300000;
const NOTIFICATION_INTERVAL = 600000;
const NOTIFICATION_EXPIRES = 300000;
function bindSubscriptionLinks() {
delegate(document, 'fetchcomplete', {
@ -19,7 +19,7 @@ function bindSubscriptionLinks() {
event.detail.text().then(text => {
target.outerHTML = text;
});
}
},
});
}

View file

@ -18,8 +18,7 @@ export function warnAboutPMs() {
if (value.match(imageEmbedRegex)) {
showEl(warning);
}
else if (!warning.classList.contains('hidden')) {
} else {
hideEl(warning);
}
});

View file

@ -110,11 +110,12 @@ function setupPreviews() {
// Fire handler for automatic resizing if textarea contains text on page load (e.g. editing)
if (textarea.value) textarea.dispatchEvent(new Event('change'));
previewAnon && previewAnon.addEventListener('click', () => {
if (previewContent.classList.contains('hidden')) return;
previewAnon &&
previewAnon.addEventListener('click', () => {
if (previewContent.classList.contains('hidden')) return;
updatePreview();
});
updatePreview();
});
document.addEventListener('click', event => {
if (event.target && event.target.closest('.post-reply')) {

View file

@ -97,7 +97,9 @@ describe('Date parsing', () => {
});
it('should not match malformed absolute date expressions', () => {
expect(() => makeDateMatcher('2024-06-21T06:21:30+01:3020', 'eq')).toThrow('Cannot parse date string: 2024-06-21T06:21:30+01:3020');
expect(() => makeDateMatcher('2024-06-21T06:21:30+01:3020', 'eq')).toThrow(
'Cannot parse date string: 2024-06-21T06:21:30+01:3020',
);
});
it('should not match malformed relative date expressions', () => {

View file

@ -4,10 +4,10 @@ describe('User field parsing', () => {
beforeEach(() => {
/* eslint-disable camelcase */
window.booru.interactions = [
{image_id: 0, user_id: 0, interaction_type: 'faved', value: null},
{image_id: 0, user_id: 0, interaction_type: 'voted', value: 'up'},
{image_id: 1, user_id: 0, interaction_type: 'voted', value: 'down'},
{image_id: 2, user_id: 0, interaction_type: 'hidden', value: null},
{ image_id: 0, user_id: 0, interaction_type: 'faved', value: null },
{ image_id: 0, user_id: 0, interaction_type: 'voted', value: 'up' },
{ image_id: 1, user_id: 0, interaction_type: 'voted', value: 'down' },
{ image_id: 2, user_id: 0, interaction_type: 'hidden', value: null },
];
/* eslint-enable camelcase */
});

View file

@ -44,7 +44,7 @@ function makeRelativeDateMatcher(dateVal: string, qual: RangeEqualQualifier): Fi
day: 86400000,
week: 604800000,
month: 2592000000,
year: 31536000000
year: 31536000000,
};
const amount = parseInt(match[1], 10);
@ -57,15 +57,22 @@ function makeRelativeDateMatcher(dateVal: string, qual: RangeEqualQualifier): Fi
return makeMatcher(bottomDate, topDate, qual);
}
const parseRes: RegExp[] = [
// year
/^(\d{4})/,
// month
/^-(\d{2})/,
// day
/^-(\d{2})/,
// hour
/^(?:\s+|T|t)(\d{2})/,
// minute
/^:(\d{2})/,
// second
/^:(\d{2})/,
];
function makeAbsoluteDateMatcher(dateVal: string, qual: RangeEqualQualifier): FieldMatcher {
const parseRes: RegExp[] = [
/^(\d{4})/,
/^-(\d{2})/,
/^-(\d{2})/,
/^(?:\s+|T|t)(\d{2})/,
/^:(\d{2})/,
/^:(\d{2})/
];
const timeZoneOffset: TimeZoneOffset = [0, 0];
const timeData: AbsoluteDate = [0, 0, 1, 0, 0, 0];
@ -81,8 +88,7 @@ function makeAbsoluteDateMatcher(dateVal: string, qual: RangeEqualQualifier): Fi
timeZoneOffset[1] *= -1;
}
localDateVal = localDateVal.substring(0, localDateVal.length - 6);
}
else {
} else {
localDateVal = localDateVal.replace(/[Zz]$/, '');
}
@ -97,16 +103,14 @@ function makeAbsoluteDateMatcher(dateVal: string, qual: RangeEqualQualifier): Fi
if (matchIndex === 1) {
// Months are offset by 1.
timeData[matchIndex] = parseInt(componentMatch[1], 10) - 1;
}
else {
} else {
// All other components are not offset.
timeData[matchIndex] = parseInt(componentMatch[1], 10);
}
// Truncate string.
localDateVal = localDateVal.substring(componentMatch[0].length);
}
else {
} else {
throw new ParseError(`Cannot parse date string: ${origDateVal}`);
}
}

View file

@ -2,16 +2,23 @@ import { FieldName } from './types';
type AttributeName = string;
export const numberFields: FieldName[] =
['id', 'width', 'height', 'aspect_ratio',
'comment_count', 'score', 'upvotes', 'downvotes',
'faves', 'tag_count', 'score'];
export const numberFields: FieldName[] = [
'id',
'width',
'height',
'aspect_ratio',
'comment_count',
'score',
'upvotes',
'downvotes',
'faves',
'tag_count',
'score',
];
export const dateFields: FieldName[] = ['created_at'];
export const literalFields =
['tags', 'orig_sha512_hash', 'sha512_hash',
'uploader', 'source_url', 'description'];
export const literalFields = ['tags', 'orig_sha512_hash', 'sha512_hash', 'uploader', 'source_url', 'description'];
export const termSpaceToImageField: Record<FieldName, AttributeName> = {
tags: 'data-image-tag-aliases',
@ -32,7 +39,7 @@ export const termSpaceToImageField: Record<FieldName, AttributeName> = {
faves: 'data-faves',
sha512_hash: 'data-sha512',
orig_sha512_hash: 'data-orig-sha512',
created_at: 'data-created-at'
created_at: 'data-created-at',
/* eslint-enable camelcase */
};

View file

@ -17,7 +17,7 @@ const tokenList: Token[] = [
['not_op', /^\s*[!-]\s*/],
['space', /^\s+/],
['word', /^(?:\\[\s,()^~]|[^\s,()^~])+/],
['word', /^(?:\\[\s,()]|[^\s,()])+/]
['word', /^(?:\\[\s,()]|[^\s,()])+/],
];
export type ParseTerm = (term: string, fuzz: number, boost: number) => AstMatcher;
@ -26,14 +26,14 @@ export type Range = [number, number];
export type TermContext = [Range, string];
export interface LexResult {
tokenList: TokenList,
termContexts: TermContext[],
error: ParseError | null
tokenList: TokenList;
termContexts: TermContext[];
error: ParseError | null;
}
export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexResult {
const opQueue: string[] = [],
groupNegate: boolean[] = [];
const opQueue: string[] = [];
const groupNegate: boolean[] = [];
let searchTerm: string | null = null;
let boostFuzzStr = '';
@ -49,7 +49,7 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR
const ret: LexResult = {
tokenList: [],
termContexts: [],
error: null
error: null,
};
const beginTerm = (token: string) => {
@ -85,8 +85,10 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR
}
const token = match[0];
const tokenIsBinaryOp = ['and_op', 'or_op'].indexOf(tokenName) !== -1;
const tokenIsGroupStart = tokenName === 'rparen' && lparenCtr === 0;
if (searchTerm !== null && (['and_op', 'or_op'].indexOf(tokenName) !== -1 || tokenName === 'rparen' && lparenCtr === 0)) {
if (searchTerm !== null && (tokenIsBinaryOp || tokenIsGroupStart)) {
endTerm();
}
@ -107,8 +109,7 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR
if (searchTerm) {
// We're already inside a search term, so it does not apply, obv.
searchTerm += token;
}
else {
} else {
negate = !negate;
}
break;
@ -118,8 +119,7 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR
// instead, consider it as part of the search term, as a user convenience.
searchTerm += token;
lparenCtr += 1;
}
else {
} else {
opQueue.unshift('lparen');
groupNegate.push(negate);
negate = false;
@ -129,8 +129,7 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR
if (lparenCtr > 0) {
searchTerm = assertNotNull(searchTerm) + token;
lparenCtr -= 1;
}
else {
} else {
while (opQueue.length > 0) {
const op = assertNotUndefined(opQueue.shift());
if (op === 'lparen') {
@ -149,8 +148,7 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR
// to a temporary string in case this is actually inside the term.
fuzz = parseFloat(token.substring(1));
boostFuzzStr += token;
}
else {
} else {
beginTerm(token);
}
break;
@ -158,16 +156,14 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR
if (searchTerm) {
boost = parseFloat(token.substring(1));
boostFuzzStr += token;
}
else {
} else {
beginTerm(token);
}
break;
case 'quoted_lit':
if (searchTerm) {
searchTerm += token;
}
else {
} else {
beginTerm(token);
}
break;
@ -180,8 +176,7 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR
boostFuzzStr = '';
}
searchTerm += token;
}
else {
} else {
beginTerm(token);
}
break;

View file

@ -22,13 +22,15 @@ function makeWildcardMatcher(term: string): FieldMatcher {
// Transforms wildcard match into regular expression.
// A custom NFA with caching may be more sophisticated but not
// likely to be faster.
const wildcard = new RegExp(
`^${term.replace(/([.+^$[\]\\(){}|-])/g, '\\$1')
.replace(/([^\\]|[^\\](?:\\\\)+)\*/g, '$1.*')
.replace(/^(?:\\\\)*\*/g, '.*')
.replace(/([^\\]|[^\\](?:\\\\)+)\?/g, '$1.?')
.replace(/^(?:\\\\)*\?/g, '.?')}$`, 'i'
);
const regexpForm = term
.replace(/([.+^$[\]\\(){}|-])/g, '\\$1')
.replace(/([^\\]|[^\\](?:\\\\)+)\*/g, '$1.*')
.replace(/^(?:\\\\)*\*/g, '.*')
.replace(/([^\\]|[^\\](?:\\\\)+)\?/g, '$1.?')
.replace(/^(?:\\\\)*\?/g, '.?');
const wildcard = new RegExp(`^${regexpForm}$`, 'i');
return (v, name) => {
const values = extractValues(v, name);
@ -69,10 +71,9 @@ function fuzzyMatch(term: string, targetStr: string, fuzz: number): boolean {
// Insertion.
v2[j] + 1,
// Substitution or No Change.
v1[j] + cost
v1[j] + cost,
);
if (i > 1 && j > 1 && term[i] === targetStrLower[j - 1] &&
targetStrLower[i - 1] === targetStrLower[j]) {
if (i > 1 && j > 1 && term[i] === targetStrLower[j - 1] && targetStrLower[i - 1] === targetStrLower[j]) {
v2[j + 1] = Math.min(v2[j], v0[j - 1] + cost);
}
}

View file

@ -6,10 +6,10 @@ import { makeUserMatcher } from './user';
import { FieldMatcher, RangeEqualQualifier } from './types';
export interface MatcherFactory {
makeDateMatcher: (dateVal: string, qual: RangeEqualQualifier) => FieldMatcher,
makeLiteralMatcher: (term: string, fuzz: number, wildcardable: boolean) => FieldMatcher,
makeNumberMatcher: (term: number, fuzz: number, qual: RangeEqualQualifier) => FieldMatcher,
makeUserMatcher: (term: string) => FieldMatcher
makeDateMatcher: (dateVal: string, qual: RangeEqualQualifier) => FieldMatcher;
makeLiteralMatcher: (term: string, fuzz: number, wildcardable: boolean) => FieldMatcher;
makeNumberMatcher: (term: number, fuzz: number, qual: RangeEqualQualifier) => FieldMatcher;
makeUserMatcher: (term: string) => FieldMatcher;
}
export const defaultMatcher: MatcherFactory = {

View file

@ -23,19 +23,16 @@ export function parseTokens(lexicalArray: TokenList): AstMatcher {
if (token === 'and_op') {
intermediate = matchAll(op1, op2);
}
else {
} else {
intermediate = matchAny(op1, op2);
}
}
else {
} else {
intermediate = token;
}
if (lexicalArray[i + 1] === 'not_op') {
operandStack.push(matchNot(intermediate));
}
else {
} else {
operandStack.push(intermediate);
}
}

View file

@ -67,11 +67,9 @@ function makeTermMatcher(term: string, fuzz: number, factory: MatcherFactory): [
}
return [fieldName, factory.makeNumberMatcher(parseFloat(termCandidate), fuzz, rangeType)];
}
else if (literalFields.indexOf(candidateTermSpace) !== -1) {
} else if (literalFields.indexOf(candidateTermSpace) !== -1) {
return [candidateTermSpace, factory.makeLiteralMatcher(termCandidate, fuzz, wildcardable)];
}
else if (candidateTermSpace === 'my') {
} else if (candidateTermSpace === 'my') {
return [candidateTermSpace, factory.makeUserMatcher(termCandidate)];
}
}

View file

@ -1,8 +1,15 @@
import { Interaction, InteractionType, InteractionValue } from '../../types/booru-object';
import { FieldMatcher } from './types';
function interactionMatch(imageId: number, type: InteractionType, value: InteractionValue, interactions: Interaction[]): boolean {
return interactions.some(v => v.image_id === imageId && v.interaction_type === type && (value === null || v.value === value));
function interactionMatch(
imageId: number,
type: InteractionType,
value: InteractionValue,
interactions: Interaction[],
): boolean {
return interactions.some(
v => v.image_id === imageId && v.interaction_type === type && (value === null || v.value === value),
);
}
export function makeUserMatcher(term: string): FieldMatcher {

View file

@ -9,56 +9,54 @@ import { fetchJson, handleError } from './utils/requests';
const imageQueueStorage = 'quickTagQueue';
const currentTagStorage = 'quickTagName';
function currentQueue() { return store.get(imageQueueStorage) || []; }
function currentQueue() {
return store.get(imageQueueStorage) || [];
}
function currentTags() { return store.get(currentTagStorage) || ''; }
function currentTags() {
return store.get(currentTagStorage) || '';
}
function getTagButton() { return $('.js-quick-tag'); }
function getTagButton() {
return $('.js-quick-tag');
}
function setTagButton(text) { $('.js-quick-tag--submit span').textContent = text; }
function setTagButton(text) {
$('.js-quick-tag--submit span').textContent = text;
}
function toggleActiveState() {
toggleEl($('.js-quick-tag'),
$('.js-quick-tag--abort'),
$('.js-quick-tag--all'),
$('.js-quick-tag--submit'));
toggleEl($('.js-quick-tag'), $('.js-quick-tag--abort'), $('.js-quick-tag--all'), $('.js-quick-tag--submit'));
setTagButton(`Submit (${currentTags()})`);
$$('.media-box__header').forEach(el => el.classList.toggle('media-box__header--unselected'));
$$('.media-box__header').forEach(el => el.classList.remove('media-box__header--selected'));
currentQueue().forEach(id => $$(`.media-box__header[data-image-id="${id}"]`).forEach(el => el.classList.add('media-box__header--selected')));
currentQueue().forEach(id =>
$$(`.media-box__header[data-image-id="${id}"]`).forEach(el => el.classList.add('media-box__header--selected')),
);
}
function activate() {
store.set(currentTagStorage, window.prompt('A comma-delimited list of tags you want to add:'));
if (currentTags()) toggleActiveState();
}
function reset() {
store.remove(currentTagStorage);
store.remove(imageQueueStorage);
toggleActiveState();
}
function promptReset() {
if (window.confirm('Are you sure you want to abort batch tagging?')) {
reset();
}
}
function submit() {
setTagButton(`Wait... (${currentTags()})`);
fetchJson('PUT', '/admin/batch/tags', {
@ -68,30 +66,26 @@ function submit() {
.then(handleError)
.then(r => r.json())
.then(data => {
if (data.failed.length) window.alert(`Failed to add tags to the images with these IDs: ${data.failed}`);
reset();
});
}
function modifyImageQueue(mediaBox) {
if (currentTags()) {
const imageId = mediaBox.dataset.imageId,
queue = currentQueue(),
isSelected = queue.includes(imageId);
const imageId = mediaBox.dataset.imageId;
const queue = currentQueue();
const isSelected = queue.includes(imageId);
isSelected ? queue.splice(queue.indexOf(imageId), 1)
: queue.push(imageId);
isSelected ? queue.splice(queue.indexOf(imageId), 1) : queue.push(imageId);
$$(`.media-box__header[data-image-id="${imageId}"]`).forEach(el => el.classList.toggle('media-box__header--selected'));
$$(`.media-box__header[data-image-id="${imageId}"]`).forEach(el =>
el.classList.toggle('media-box__header--selected'),
);
store.set(imageQueueStorage, queue);
}
}
function toggleAllImages() {
@ -99,7 +93,6 @@ function toggleAllImages() {
}
function clickHandler(event) {
const targets = {
'.js-quick-tag': activate,
'.js-quick-tag--abort': promptReset,
@ -114,14 +107,11 @@ function clickHandler(event) {
currentTags() && event.preventDefault();
}
}
}
function setupQuickTag() {
if (getTagButton() && currentTags()) toggleActiveState();
if (getTagButton()) onLeftClick(clickHandler);
}
export { setupQuickTag };

View file

@ -3,14 +3,17 @@ let mediaContainers;
/* Hardcoded dimensions of thumb boxes; at mediaLargeMinSize, large box becomes a small one (font size gets diminished).
* At minimum width, the large box still has four digit fave/score numbers and five digit comment number fitting in a single row
* (small box may lose the number of comments in a hidden overflow) */
const mediaLargeMaxSize = 250, mediaLargeMinSize = 190, mediaSmallMaxSize = 156, mediaSmallMinSize = 140;
const mediaLargeMaxSize = 250,
mediaLargeMinSize = 190,
mediaSmallMaxSize = 156,
mediaSmallMinSize = 140;
/* Margin between thumbs (6) + borders (2) + 1 extra px to correct rounding errors */
const mediaBoxOffset = 9;
export function processResizableMedia() {
[].slice.call(mediaContainers).forEach(container => {
const containerHasLargeBoxes = container.querySelector('.media-box__content--large') !== null,
containerWidth = container.offsetWidth - 14; /* subtract container padding */
containerWidth = container.offsetWidth - 14; /* subtract container padding */
/* If at least three large boxes fit in a single row, we do not downsize them to small ones.
* This ensures that desktop users get less boxes in a row, but with bigger images inside. */
@ -21,9 +24,8 @@ export function processResizableMedia() {
/* Larger boxes are preferred to more items in a row */
setMediaSize(container, containerWidth, mediaLargeMinSize, mediaLargeMaxSize);
}
}
/* Mobile users, on the other hand, should get as many boxes in a row as possible */
else {
} else {
/* Mobile users, on the other hand, should get as many boxes in a row as possible */
setMediaSize(container, containerWidth, mediaSmallMinSize, mediaSmallMaxSize);
}
});
@ -43,8 +45,7 @@ function applyMediaSize(container, size) {
* To prevent that, we add a class that diminishes its padding and font size. */
if (size < mediaLargeMinSize) {
header.classList.add('media-box__header--small');
}
else {
} else {
header.classList.remove('media-box__header--small');
}
});
@ -52,9 +53,9 @@ function applyMediaSize(container, size) {
function setMediaSize(container, containerWidth, minMediaSize, maxMediaSize) {
const maxThumbsFitting = Math.floor(containerWidth / (minMediaSize + mediaBoxOffset)),
minThumbsFitting = Math.floor(containerWidth / (maxMediaSize + mediaBoxOffset)),
fitThumbs = Math.round((maxThumbsFitting + minThumbsFitting) / 2),
thumbSize = Math.max(Math.floor(containerWidth / fitThumbs) - 9, minMediaSize);
minThumbsFitting = Math.floor(containerWidth / (maxMediaSize + mediaBoxOffset)),
fitThumbs = Math.round((maxThumbsFitting + minThumbsFitting) / 2),
thumbSize = Math.max(Math.floor(containerWidth / fitThumbs) - 9, minMediaSize);
applyMediaSize(container, thumbSize);
}

View file

@ -6,8 +6,7 @@ function showHelp(subject, type) {
if (helpBox.getAttribute('data-search-help') === type) {
$('.js-search-help-subject', helpBox).textContent = subject;
helpBox.classList.remove('hidden');
}
else {
} else {
helpBox.classList.add('hidden');
}
});
@ -16,7 +15,8 @@ function showHelp(subject, type) {
function prependToLast(field, value) {
const separatorIndex = field.value.lastIndexOf(',');
const advanceBy = field.value[separatorIndex + 1] === ' ' ? 2 : 1;
field.value = field.value.slice(0, separatorIndex + advanceBy) + value + field.value.slice(separatorIndex + advanceBy);
field.value =
field.value.slice(0, separatorIndex + advanceBy) + value + field.value.slice(separatorIndex + advanceBy);
}
function selectLast(field, characterCount) {

View file

@ -7,7 +7,6 @@ import { $, $$ } from './utils/dom';
import store from './utils/store';
export function setupSettings() {
if (!$('#js-setting-table')) return;
const localCheckboxes = $$<HTMLInputElement>('[data-tab="local"] input[type="checkbox"]');

View file

@ -37,31 +37,39 @@ function click(selector: string) {
}
function isOK(event: KeyboardEvent): boolean {
return !event.altKey && !event.ctrlKey && !event.metaKey &&
document.activeElement !== null &&
document.activeElement.tagName !== 'INPUT' &&
document.activeElement.tagName !== 'TEXTAREA';
return (
!event.altKey &&
!event.ctrlKey &&
!event.metaKey &&
document.activeElement !== null &&
document.activeElement.tagName !== 'INPUT' &&
document.activeElement.tagName !== 'TEXTAREA'
);
}
/* eslint-disable prettier/prettier */
const keyCodes: ShortcutKeyMap = {
'j'() { click('.js-prev'); }, // J - go to previous image
'i'() { click('.js-up'); }, // I - go to index page
'k'() { click('.js-next'); }, // K - go to next image
'r'() { click('.js-rand'); }, // R - go to random image
's'() { click('.js-source-link'); }, // S - go to image source
'l'() { click('.js-tag-sauce-toggle'); }, // L - edit tags
'o'() { openFullView(); }, // O - open original
'v'() { openFullViewNewTab(); }, // V - open original in a new tab
'f'() { // F - favourite image
click(getHover() ? `a.interaction--fave[data-image-id="${getHover()}"]`
: '.block__header a.interaction--fave');
j() { click('.js-prev'); }, // J - go to previous image
i() { click('.js-up'); }, // I - go to index page
k() { click('.js-next'); }, // K - go to next image
r() { click('.js-rand'); }, // R - go to random image
s() { click('.js-source-link'); }, // S - go to image source
l() { click('.js-tag-sauce-toggle'); }, // L - edit tags
o() { openFullView(); }, // O - open original
v() { openFullViewNewTab(); }, // V - open original in a new tab
f() {
// F - favourite image
click(getHover() ? `a.interaction--fave[data-image-id="${getHover()}"]` : '.block__header a.interaction--fave');
},
'u'() { // U - upvote image
click(getHover() ? `a.interaction--upvote[data-image-id="${getHover()}"]`
: '.block__header a.interaction--upvote');
u() {
// U - upvote image
click(getHover() ? `a.interaction--upvote[data-image-id="${getHover()}"]` : '.block__header a.interaction--upvote');
},
};
/* eslint-enable prettier/prettier */
export function listenForKeys() {
document.addEventListener('keydown', (event: KeyboardEvent) => {
if (isOK(event) && keyCodes[event.key]) {

View file

@ -19,10 +19,14 @@ function removeTag(tagId: number, list: number[]) {
function createTagDropdown(tag: HTMLSpanElement) {
const { userIsSignedIn, userCanEditFilter, watchedTagList, spoileredTagList, hiddenTagList } = window.booru;
const [ unwatch, watch, unspoiler, spoiler, unhide, hide, signIn, filter ] = $$<HTMLElement>('.tag__dropdown__link', tag);
const [ unwatched, watched, spoilered, hidden ] = $$<HTMLSpanElement>('.tag__state', tag);
const [unwatch, watch, unspoiler, spoiler, unhide, hide, signIn, filter] = $$<HTMLElement>(
'.tag__dropdown__link',
tag,
);
const [unwatched, watched, spoilered, hidden] = $$<HTMLSpanElement>('.tag__state', tag);
const tagId = parseInt(assertNotUndefined(tag.dataset.tagId), 10);
/* eslint-disable prettier/prettier */
const actions: TagDropdownActionList = {
unwatch() { hideEl(unwatch, watched); showEl(watch, unwatched); removeTag(tagId, watchedTagList); },
watch() { hideEl(watch, unwatched); showEl(unwatch, watched); addTag(tagId, watchedTagList); },
@ -33,28 +37,28 @@ function createTagDropdown(tag: HTMLSpanElement) {
unhide() { hideEl(unhide, hidden); showEl(hide); removeTag(tagId, hiddenTagList); },
hide() { hideEl(hide); showEl(unhide, hidden); addTag(tagId, hiddenTagList); },
};
/* eslint-enable prettier/prettier */
const tagIsWatched = watchedTagList.includes(tagId);
const tagIsWatched = watchedTagList.includes(tagId);
const tagIsSpoilered = spoileredTagList.includes(tagId);
const tagIsHidden = hiddenTagList.includes(tagId);
const tagIsHidden = hiddenTagList.includes(tagId);
const watchedLink = tagIsWatched ? unwatch : watch;
const spoilerLink = tagIsSpoilered ? unspoiler : spoiler;
const hiddenLink = tagIsHidden ? unhide : hide;
const watchedLink = tagIsWatched ? unwatch : watch;
const spoilerLink = tagIsSpoilered ? unspoiler : spoiler;
const hiddenLink = tagIsHidden ? unhide : hide;
// State symbols (-, S, H, +)
if (tagIsWatched) showEl(watched);
if (tagIsSpoilered) showEl(spoilered);
if (tagIsHidden) showEl(hidden);
if (!tagIsWatched) showEl(unwatched);
if (tagIsWatched) showEl(watched);
if (tagIsSpoilered) showEl(spoilered);
if (tagIsHidden) showEl(hidden);
if (!tagIsWatched) showEl(unwatched);
// Dropdown links
if (userIsSignedIn) showEl(watchedLink);
if (userIsSignedIn) showEl(watchedLink);
if (userCanEditFilter) showEl(spoilerLink);
if (userCanEditFilter) showEl(hiddenLink);
if (!userIsSignedIn) showEl(signIn);
if (userIsSignedIn &&
!userCanEditFilter) showEl(filter);
if (!userIsSignedIn) showEl(signIn);
if (userIsSignedIn && !userCanEditFilter) showEl(filter);
tag.addEventListener('fetchcomplete', event => {
const act = assertNotUndefined(event.target.dataset.tagAction);

View file

@ -5,7 +5,7 @@
import { $, $$, clearEl, removeEl, showEl, hideEl, escapeCss, escapeHtml } from './utils/dom';
function setupTagsInput(tagBlock) {
const [ textarea, container ] = $$('.js-taginput', tagBlock);
const [textarea, container] = $$('.js-taginput', tagBlock);
const setup = $('.js-tag-block ~ button', tagBlock.parentNode);
const inputField = $('input', container);
@ -42,7 +42,6 @@ function setupTagsInput(tagBlock) {
importTags();
}
function handleAutocomplete(event) {
insertTag(event.detail.value);
inputField.focus();
@ -85,7 +84,6 @@ function setupTagsInput(tagBlock) {
inputField.value.split(',').forEach(t => insertTag(t));
inputField.value = '';
}
}
function handleCtrlEnter(event) {
@ -138,8 +136,10 @@ function setupTagsInput(tagBlock) {
function fancyEditorRequested(tagBlock) {
// Check whether the user made the fancy editor the default for each type of tag block.
return window.booru.fancyTagUpload && tagBlock.classList.contains('fancy-tag-upload') ||
window.booru.fancyTagEdit && tagBlock.classList.contains('fancy-tag-edit');
return (
(window.booru.fancyTagUpload && tagBlock.classList.contains('fancy-tag-upload')) ||
(window.booru.fancyTagEdit && tagBlock.classList.contains('fancy-tag-edit'))
);
}
function setupTagListener() {

View file

@ -30,7 +30,7 @@ function tagInputButtons(event: MouseEvent) {
},
};
for (const [ name, action ] of Object.entries(actions)) {
for (const [name, action] of Object.entries(actions)) {
if (target && target.matches(`#tagsinput-${name}`)) {
action(assertNotNull($<HTMLTextAreaElement>('#image_tag_input')));
}

View file

@ -35,25 +35,25 @@ function setTimeAgo(el: HTMLTimeElement) {
const date = new Date(datetime);
const distMillis = distance(date);
const seconds = Math.abs(distMillis) / 1000,
minutes = seconds / 60,
hours = minutes / 60,
days = hours / 24,
months = days / 30,
years = days / 365;
const seconds = Math.abs(distMillis) / 1000;
const minutes = seconds / 60;
const hours = minutes / 60;
const days = hours / 24;
const months = days / 30;
const years = days / 365;
const words =
seconds < 45 && substitute('seconds', seconds) ||
seconds < 90 && substitute('minute', 1) ||
minutes < 45 && substitute('minutes', minutes) ||
minutes < 90 && substitute('hour', 1) ||
hours < 24 && substitute('hours', hours) ||
hours < 42 && substitute('day', 1) ||
days < 30 && substitute('days', days) ||
days < 45 && substitute('month', 1) ||
days < 365 && substitute('months', months) ||
years < 1.5 && substitute('year', 1) ||
substitute('years', years);
(seconds < 45 && substitute('seconds', seconds)) ||
(seconds < 90 && substitute('minute', 1)) ||
(minutes < 45 && substitute('minutes', minutes)) ||
(minutes < 90 && substitute('hour', 1)) ||
(hours < 24 && substitute('hours', hours)) ||
(hours < 42 && substitute('day', 1)) ||
(days < 30 && substitute('days', days)) ||
(days < 45 && substitute('month', 1)) ||
(days < 365 && substitute('months', months)) ||
(years < 1.5 && substitute('year', 1)) ||
substitute('years', years);
if (!el.getAttribute('title')) {
el.setAttribute('title', assertNotNull(el.textContent));

View file

@ -4,7 +4,7 @@ import { fire, delegate, leftClick } from './utils/events';
const headers = () => ({
'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'XMLHttpRequest'
'x-requested-with': 'XMLHttpRequest',
});
function confirm(event: Event, target: HTMLElement) {
@ -25,8 +25,7 @@ function disable(event: Event, target: HTMLAnchorElement | HTMLButtonElement | H
if (label) {
target.dataset.enableWith = assertNotNull(label.nodeValue);
label.nodeValue = ` ${target.dataset.disableWith}`;
}
else {
} else {
target.dataset.enableWith = target.innerHTML;
target.innerHTML = assertNotUndefined(target.dataset.disableWith);
}
@ -39,8 +38,8 @@ function disable(event: Event, target: HTMLAnchorElement | HTMLButtonElement | H
function linkMethod(event: Event, target: HTMLAnchorElement) {
event.preventDefault();
const form = makeEl('form', { action: target.href, method: 'POST' });
const csrf = makeEl('input', { type: 'hidden', name: '_csrf_token', value: window.booru.csrfToken });
const form = makeEl('form', { action: target.href, method: 'POST' });
const csrf = makeEl('input', { type: 'hidden', name: '_csrf_token', value: window.booru.csrfToken });
const method = makeEl('input', { type: 'hidden', name: '_method', value: target.dataset.method });
document.body.appendChild(form);
@ -57,7 +56,7 @@ function formRemote(event: Event, target: HTMLFormElement) {
credentials: 'same-origin',
method: (target.dataset.method || target.method).toUpperCase(),
headers: headers(),
body: new FormData(target)
body: new FormData(target),
}).then(response => {
fire(target, 'fetchcomplete', response);
if (response && response.status === 300) {
@ -71,8 +70,7 @@ function formReset(_event: Event | null, target: HTMLElement) {
const label = findFirstTextNode(input);
if (label) {
label.nodeValue = ` ${input.dataset.enableWith}`;
}
else {
} else {
input.innerHTML = assertNotUndefined(input.dataset.enableWith);
}
delete input.dataset.enableWith;
@ -86,10 +84,8 @@ function linkRemote(event: Event, target: HTMLAnchorElement) {
fetch(target.href, {
credentials: 'same-origin',
method: (target.dataset.method || 'get').toUpperCase(),
headers: headers()
}).then(response =>
fire(target, 'fetchcomplete', response)
);
headers: headers(),
}).then(response => fire(target, 'fetchcomplete', response));
}
delegate(document, 'click', {
@ -100,11 +96,11 @@ delegate(document, 'click', {
});
delegate(document, 'submit', {
'form[data-remote]': formRemote
'form[data-remote]': formRemote,
});
delegate(document, 'reset', {
form: formReset
form: formReset,
});
window.addEventListener('pageshow', () => {

View file

@ -68,17 +68,25 @@ function setupImageUpload() {
showEl(scraperError);
enableFetch();
}
function hideError() { hideEl(scraperError); }
function disableFetch() { fetchButton.setAttribute('disabled', ''); }
function enableFetch() { fetchButton.removeAttribute('disabled'); }
function hideError() {
hideEl(scraperError);
}
function disableFetch() {
fetchButton.setAttribute('disabled', '');
}
function enableFetch() {
fetchButton.removeAttribute('disabled');
}
const reader = new FileReader();
reader.addEventListener('load', event => {
showImages([{
camo_url: event.target.result,
type: fileField.files[0].type
}]);
showImages([
{
camo_url: event.target.result,
type: fileField.files[0].type,
},
]);
// Clear any currently cached data, because the file field
// has higher priority than the scraper:
@ -88,7 +96,9 @@ function setupImageUpload() {
});
// Watch for files added to the form
fileField.addEventListener('change', () => { fileField.files.length && reader.readAsArrayBuffer(fileField.files[0]); });
fileField.addEventListener('change', () => {
fileField.files.length && reader.readAsArrayBuffer(fileField.files[0]);
});
// Watch for [Fetch] clicks
fetchButton.addEventListener('click', () => {
@ -96,37 +106,39 @@ function setupImageUpload() {
disableFetch();
scrapeUrl(remoteUrl.value).then(data => {
if (data === null) {
scraperError.innerText = 'No image found at that address.';
showError();
return;
}
else if (data.errors && data.errors.length > 0) {
scraperError.innerText = data.errors.join(' ');
showError();
return;
}
scrapeUrl(remoteUrl.value)
.then(data => {
if (data === null) {
scraperError.innerText = 'No image found at that address.';
showError();
return;
} else if (data.errors && data.errors.length > 0) {
scraperError.innerText = data.errors.join(' ');
showError();
return;
}
hideError();
hideError();
// Set source
if (sourceEl) sourceEl.value = sourceEl.value || data.source_url || '';
// Set description
if (descrEl) descrEl.value = descrEl.value || data.description || '';
// Add author
if (tagsEl && data.author_name) addTag(tagsEl, `artist:${data.author_name.toLowerCase()}`);
// Clear selected file, if any
fileField.value = '';
showImages(data.images);
// Set source
if (sourceEl) sourceEl.value = sourceEl.value || data.source_url || '';
// Set description
if (descrEl) descrEl.value = descrEl.value || data.description || '';
// Add author
if (tagsEl && data.author_name) addTag(tagsEl, `artist:${data.author_name.toLowerCase()}`);
// Clear selected file, if any
fileField.value = '';
showImages(data.images);
enableFetch();
}).catch(showError);
enableFetch();
})
.catch(showError);
});
// Fetch on "enter" in url field
remoteUrl.addEventListener('keydown', event => {
if (event.keyCode === 13) { // Hit enter
if (event.keyCode === 13) {
// Hit enter
fetchButton.click();
}
});
@ -135,8 +147,7 @@ function setupImageUpload() {
function setFetchEnabled() {
if (remoteUrl.value.length > 0) {
enableFetch();
}
else {
} else {
disableFetch();
}
}

View file

@ -84,15 +84,17 @@ describe('Array Utilities', () => {
// Mixed parameters
const mockObject = { value: Math.random() };
expect(arraysEqual(
['', null, false, uniqueValue, mockObject, Infinity, undefined],
['', null, false, uniqueValue, mockObject, Infinity, undefined]
)).toBe(true);
expect(
arraysEqual(
['', null, false, uniqueValue, mockObject, Infinity, undefined],
['', null, false, uniqueValue, mockObject, Infinity, undefined],
),
).toBe(true);
});
});
describe('negative cases', () => {
it('should NOT return true for matching only up to the first array\'s length', () => {
it("should NOT return true for matching only up to the first array's length", () => {
// Numbers
expect(arraysEqual([0], [0, 1])).toBe(false);
expect(arraysEqual([0, 1], [0, 1, 2])).toBe(false);
@ -108,26 +110,15 @@ describe('Array Utilities', () => {
// Mixed parameters
const mockObject = { value: Math.random() };
expect(arraysEqual(
[''],
['', null, false, mockObject, Infinity, undefined]
)).toBe(false);
expect(arraysEqual(
['', null],
['', null, false, mockObject, Infinity, undefined]
)).toBe(false);
expect(arraysEqual(
['', null, false],
['', null, false, mockObject, Infinity, undefined]
)).toBe(false);
expect(arraysEqual(
['', null, false, mockObject],
['', null, false, mockObject, Infinity, undefined]
)).toBe(false);
expect(arraysEqual(
['', null, false, mockObject, Infinity],
['', null, false, mockObject, Infinity, undefined]
)).toBe(false);
expect(arraysEqual([''], ['', null, false, mockObject, Infinity, undefined])).toBe(false);
expect(arraysEqual(['', null], ['', null, false, mockObject, Infinity, undefined])).toBe(false);
expect(arraysEqual(['', null, false], ['', null, false, mockObject, Infinity, undefined])).toBe(false);
expect(arraysEqual(['', null, false, mockObject], ['', null, false, mockObject, Infinity, undefined])).toBe(
false,
);
expect(
arraysEqual(['', null, false, mockObject, Infinity], ['', null, false, mockObject, Infinity, undefined]),
).toBe(false);
});
it('should return false for arrays of different length', () => {
@ -151,7 +142,7 @@ describe('Array Utilities', () => {
expect(arraysEqual([mockObject], [mockObject, mockObject])).toBe(false);
});
it('should return false if items up to the first array\'s length differ', () => {
it("should return false if items up to the first array's length differ", () => {
// Numbers
expect(arraysEqual([0], [1])).toBe(false);
expect(arraysEqual([0, 1], [1, 2])).toBe(false);
@ -168,22 +159,12 @@ describe('Array Utilities', () => {
expect(arraysEqual([mockObject1], [mockObject2])).toBe(false);
// Mixed parameters
expect(arraysEqual(
['a'],
['b', null, false, mockObject2, Infinity]
)).toBe(false);
expect(arraysEqual(
['a', null, true],
['b', null, false, mockObject2, Infinity]
)).toBe(false);
expect(arraysEqual(
['a', null, true, mockObject1],
['b', null, false, mockObject2, Infinity]
)).toBe(false);
expect(arraysEqual(
['a', null, true, mockObject1, -Infinity],
['b', null, false, mockObject2, Infinity]
)).toBe(false);
expect(arraysEqual(['a'], ['b', null, false, mockObject2, Infinity])).toBe(false);
expect(arraysEqual(['a', null, true], ['b', null, false, mockObject2, Infinity])).toBe(false);
expect(arraysEqual(['a', null, true, mockObject1], ['b', null, false, mockObject2, Infinity])).toBe(false);
expect(arraysEqual(['a', null, true, mockObject1, -Infinity], ['b', null, false, mockObject2, Infinity])).toBe(
false,
);
});
});
});

View file

@ -87,11 +87,7 @@ describe('DOM Utilities', () => {
});
it(`should remove the ${hiddenClass} class from all provided elements`, () => {
const mockElements = [
createHiddenElement('div'),
createHiddenElement('a'),
createHiddenElement('strong'),
];
const mockElements = [createHiddenElement('div'), createHiddenElement('a'), createHiddenElement('strong')];
showEl(mockElements);
expect(mockElements[0]).not.toHaveClass(hiddenClass);
expect(mockElements[1]).not.toHaveClass(hiddenClass);
@ -99,14 +95,8 @@ describe('DOM Utilities', () => {
});
it(`should remove the ${hiddenClass} class from elements provided in multiple arrays`, () => {
const mockElements1 = [
createHiddenElement('div'),
createHiddenElement('a'),
];
const mockElements2 = [
createHiddenElement('strong'),
createHiddenElement('em'),
];
const mockElements1 = [createHiddenElement('div'), createHiddenElement('a')];
const mockElements2 = [createHiddenElement('strong'), createHiddenElement('em')];
showEl(mockElements1, mockElements2);
expect(mockElements1[0]).not.toHaveClass(hiddenClass);
expect(mockElements1[1]).not.toHaveClass(hiddenClass);
@ -135,14 +125,8 @@ describe('DOM Utilities', () => {
});
it(`should add the ${hiddenClass} class to elements provided in multiple arrays`, () => {
const mockElements1 = [
document.createElement('div'),
document.createElement('a'),
];
const mockElements2 = [
document.createElement('strong'),
document.createElement('em'),
];
const mockElements1 = [document.createElement('div'), document.createElement('a')];
const mockElements2 = [document.createElement('strong'), document.createElement('em')];
hideEl(mockElements1, mockElements2);
expect(mockElements1[0]).toHaveClass(hiddenClass);
expect(mockElements1[1]).toHaveClass(hiddenClass);
@ -159,24 +143,15 @@ describe('DOM Utilities', () => {
});
it('should set the disabled attribute to true on all provided elements', () => {
const mockElements = [
document.createElement('input'),
document.createElement('button'),
];
const mockElements = [document.createElement('input'), document.createElement('button')];
disableEl(mockElements);
expect(mockElements[0]).toBeDisabled();
expect(mockElements[1]).toBeDisabled();
});
it('should set the disabled attribute to true on elements provided in multiple arrays', () => {
const mockElements1 = [
document.createElement('input'),
document.createElement('button'),
];
const mockElements2 = [
document.createElement('textarea'),
document.createElement('button'),
];
const mockElements1 = [document.createElement('input'), document.createElement('button')];
const mockElements2 = [document.createElement('textarea'), document.createElement('button')];
disableEl(mockElements1, mockElements2);
expect(mockElements1[0]).toBeDisabled();
expect(mockElements1[1]).toBeDisabled();
@ -193,24 +168,15 @@ describe('DOM Utilities', () => {
});
it('should set the disabled attribute to false on all provided elements', () => {
const mockElements = [
document.createElement('input'),
document.createElement('button'),
];
const mockElements = [document.createElement('input'), document.createElement('button')];
enableEl(mockElements);
expect(mockElements[0]).toBeEnabled();
expect(mockElements[1]).toBeEnabled();
});
it('should set the disabled attribute to false on elements provided in multiple arrays', () => {
const mockElements1 = [
document.createElement('input'),
document.createElement('button'),
];
const mockElements2 = [
document.createElement('textarea'),
document.createElement('button'),
];
const mockElements1 = [document.createElement('input'), document.createElement('button')];
const mockElements2 = [document.createElement('textarea'), document.createElement('button')];
enableEl(mockElements1, mockElements2);
expect(mockElements1[0]).toBeEnabled();
expect(mockElements1[1]).toBeEnabled();
@ -245,14 +211,8 @@ describe('DOM Utilities', () => {
});
it(`should toggle the ${hiddenClass} class on elements provided in multiple arrays`, () => {
const mockElements1 = [
createHiddenElement('div'),
document.createElement('a'),
];
const mockElements2 = [
createHiddenElement('strong'),
document.createElement('em'),
];
const mockElements1 = [createHiddenElement('div'), document.createElement('a')];
const mockElements2 = [createHiddenElement('strong'), document.createElement('em')];
toggleEl(mockElements1, mockElements2);
expect(mockElements1[0]).not.toHaveClass(hiddenClass);
expect(mockElements1[1]).toHaveClass(hiddenClass);
@ -430,8 +390,7 @@ describe('DOM Utilities', () => {
try {
whenReady(mockCallback);
expect(mockCallback).toHaveBeenCalledTimes(1);
}
finally {
} finally {
readyStateSpy.mockRestore();
}
});
@ -446,8 +405,7 @@ describe('DOM Utilities', () => {
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
expect(addEventListenerSpy).toHaveBeenNthCalledWith(1, 'DOMContentLoaded', mockCallback);
expect(mockCallback).not.toHaveBeenCalled();
}
finally {
} finally {
readyStateSpy.mockRestore();
addEventListenerSpy.mockRestore();
}
@ -456,7 +414,9 @@ describe('DOM Utilities', () => {
describe('escapeHtml', () => {
it('should replace only the expected characters with their HTML entity equivalents', () => {
expect(escapeHtml('<script src="http://example.com/?a=1&b=2"></script>')).toBe('&lt;script src=&quot;http://example.com/?a=1&amp;b=2&quot;&gt;&lt;/script&gt;');
expect(escapeHtml('<script src="http://example.com/?a=1&b=2"></script>')).toBe(
'&lt;script src=&quot;http://example.com/?a=1&amp;b=2&quot;&gt;&lt;/script&gt;',
);
});
});

View file

@ -14,7 +14,7 @@ describe('Draggable Utilities', () => {
items: items as unknown as DataTransferItemList,
setData(format: string, data: string) {
items.push({ type: format, getAsString: (callback: FunctionStringCallback) => callback(data) });
}
},
} as unknown as DataTransfer;
}
Object.assign(mockEvent, { dataTransfer });
@ -44,7 +44,6 @@ describe('Draggable Utilities', () => {
mockDraggable = createDraggableElement();
mockDragContainer.appendChild(mockDraggable);
// Redirect all document event listeners to this element for easier cleanup
documentEventListenerSpy = vi.spyOn(document, 'addEventListener').mockImplementation((...params) => {
mockDragContainer.addEventListener(...params);
@ -67,7 +66,7 @@ describe('Draggable Utilities', () => {
expect(mockDraggable).toHaveClass(draggingClass);
});
it('should add dummy data to the dragstart event if it\'s empty', () => {
it("should add dummy data to the dragstart event if it's empty", () => {
initDraggables();
const mockEvent = createDragEvent('dragstart');
@ -87,7 +86,7 @@ describe('Draggable Utilities', () => {
expect(stringValue).toEqual('');
});
it('should keep data in the dragstart event if it\'s present', () => {
it("should keep data in the dragstart event if it's present", () => {
initDraggables();
const mockTransferItemType = getRandomArrayItem(['text/javascript', 'image/jpg', 'application/json']);
@ -95,7 +94,9 @@ describe('Draggable Utilities', () => {
type: mockTransferItemType,
} as unknown as DataTransferItem;
const mockEvent = createDragEvent('dragstart', { dataTransfer: { items: [mockDataTransferItem] as unknown as DataTransferItemList } } as DragEventInit);
const mockEvent = createDragEvent('dragstart', {
dataTransfer: { items: [mockDataTransferItem] as unknown as DataTransferItemList },
} as DragEventInit);
expect(mockEvent.dataTransfer?.items).toHaveLength(1);
fireEvent(mockDraggable, mockEvent);
@ -203,8 +204,7 @@ describe('Draggable Utilities', () => {
expect(mockDropEvent.defaultPrevented).toBe(true);
expect(mockSecondDraggable).not.toHaveClass(draggingClass);
expect(mockSecondDraggable.nextElementSibling).toBe(mockDraggable);
}
finally {
} finally {
boundingBoxSpy.mockRestore();
}
});
@ -232,8 +232,7 @@ describe('Draggable Utilities', () => {
expect(mockDropEvent.defaultPrevented).toBe(true);
expect(mockSecondDraggable).not.toHaveClass(draggingClass);
expect(mockDraggable.nextElementSibling).toBe(mockSecondDraggable);
}
finally {
} finally {
boundingBoxSpy.mockRestore();
}
});
@ -254,7 +253,7 @@ describe('Draggable Utilities', () => {
});
describe('dragEnd', () => {
it('should remove dragging class from source and over class from target\'s descendants', () => {
it("should remove dragging class from source and over class from target's descendants", () => {
initDraggables();
const mockStartEvent = createDragEvent('dragstart');
@ -298,8 +297,7 @@ describe('Draggable Utilities', () => {
fireEvent(mockDraggable, mockEvent);
expect(mockEvent.dataTransfer?.effectAllowed).toBeFalsy();
}
finally {
} finally {
draggableClosestSpy.mockRestore();
}
});

View file

@ -92,7 +92,7 @@ describe('Image utils', () => {
extension: string;
videoClasses?: string[];
imgClasses?: string[];
}
};
const createMockElements = ({ videoClasses, imgClasses, extension }: CreateMockElementsOptions) => {
const mockElement = document.createElement('div');
@ -131,18 +131,11 @@ describe('Image utils', () => {
};
it('should hide the img element and show the video instead if no picture element is present', () => {
const {
mockElement,
mockImage,
playSpy,
mockVideo,
mockSize,
mockSizeUrls,
mockSpoilerOverlay,
} = createMockElements({
extension: 'webm',
videoClasses: ['hidden'],
});
const { mockElement, mockImage, playSpy, mockVideo, mockSize, mockSizeUrls, mockSpoilerOverlay } =
createMockElements({
extension: 'webm',
videoClasses: ['hidden'],
});
const result = showThumb(mockElement);
@ -181,8 +174,7 @@ describe('Image utils', () => {
const result = showThumb(mockElement);
expect(result).toBe(false);
expect(jsonParseSpy).not.toHaveBeenCalled();
}
finally {
} finally {
jsonParseSpy.mockRestore();
}
});
@ -226,13 +218,8 @@ describe('Image utils', () => {
});
it('should show the correct thumbnail image for jpg extension', () => {
const {
mockElement,
mockSizeImage,
mockSizeUrls,
mockSize,
mockSpoilerOverlay,
} = createMockElementWithPicture('jpg');
const { mockElement, mockSizeImage, mockSizeUrls, mockSize, mockSpoilerOverlay } =
createMockElementWithPicture('jpg');
const result = showThumb(mockElement);
expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]);
@ -243,13 +230,8 @@ describe('Image utils', () => {
});
it('should show the correct thumbnail image for gif extension', () => {
const {
mockElement,
mockSizeImage,
mockSizeUrls,
mockSize,
mockSpoilerOverlay,
} = createMockElementWithPicture('gif');
const { mockElement, mockSizeImage, mockSizeUrls, mockSize, mockSpoilerOverlay } =
createMockElementWithPicture('gif');
const result = showThumb(mockElement);
expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]);
@ -260,13 +242,8 @@ describe('Image utils', () => {
});
it('should show the correct thumbnail image for webm extension', () => {
const {
mockElement,
mockSpoilerOverlay,
mockSizeImage,
mockSizeUrls,
mockSize,
} = createMockElementWithPicture('webm');
const { mockElement, mockSpoilerOverlay, mockSizeImage, mockSizeUrls, mockSize } =
createMockElementWithPicture('webm');
const result = showThumb(mockElement);
expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize].replace('webm', 'gif'));
@ -284,12 +261,10 @@ describe('Image utils', () => {
});
const checkSrcsetAttribute = (size: ImageSize, x2size: ImageSize) => {
const {
mockElement,
mockSizeImage,
mockSizeUrls,
mockSpoilerOverlay,
} = createMockElementWithPicture('jpg', size);
const { mockElement, mockSizeImage, mockSizeUrls, mockSpoilerOverlay } = createMockElementWithPicture(
'jpg',
size,
);
const result = showThumb(mockElement);
expect(mockSizeImage.src).toBe(mockSizeUrls[size]);
@ -312,12 +287,10 @@ describe('Image utils', () => {
it('should NOT set srcset on img if thumbUri is a gif at small size', () => {
const mockSize = 'small';
const {
mockElement,
mockSizeImage,
mockSizeUrls,
mockSpoilerOverlay,
} = createMockElementWithPicture('gif', mockSize);
const { mockElement, mockSizeImage, mockSizeUrls, mockSpoilerOverlay } = createMockElementWithPicture(
'gif',
mockSize,
);
const result = showThumb(mockElement);
expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]);
@ -336,12 +309,7 @@ describe('Image utils', () => {
});
it('should return false if img source already matches thumbUri', () => {
const {
mockElement,
mockSizeImage,
mockSizeUrls,
mockSize,
} = createMockElementWithPicture('jpg');
const { mockElement, mockSizeImage, mockSizeUrls, mockSize } = createMockElementWithPicture('jpg');
mockSizeImage.src = mockSizeUrls[mockSize];
const result = showThumb(mockElement);
expect(result).toBe(false);
@ -408,8 +376,7 @@ describe('Image utils', () => {
expect(querySelectorSpy).toHaveBeenCalledTimes(2);
expect(querySelectorSpy).toHaveBeenNthCalledWith(1, 'picture');
expect(querySelectorSpy).toHaveBeenNthCalledWith(2, 'video');
}
finally {
} finally {
querySelectorSpy.mockRestore();
}
});
@ -430,8 +397,7 @@ describe('Image utils', () => {
expect(querySelectorSpy).toHaveBeenNthCalledWith(3, 'img');
expect(querySelectorSpy).toHaveBeenNthCalledWith(4, `.${spoilerOverlayClass}`);
expect(mockVideo).not.toHaveClass(hiddenClass);
}
finally {
} finally {
querySelectorSpy.mockRestore();
pauseSpy.mockRestore();
}
@ -458,8 +424,7 @@ describe('Image utils', () => {
expect(mockVideo).toBeEmptyDOMElement();
expect(mockVideo).toHaveClass(hiddenClass);
expect(pauseSpy).toHaveBeenCalled();
}
finally {
} finally {
pauseSpy.mockRestore();
}
});
@ -482,8 +447,7 @@ describe('Image utils', () => {
expect(imgQuerySelectorSpy).toHaveBeenNthCalledWith(1, 'picture');
expect(pictureQuerySelectorSpy).toHaveBeenNthCalledWith(1, 'img');
expect(imgQuerySelectorSpy).toHaveBeenNthCalledWith(2, `.${spoilerOverlayClass}`);
}
finally {
} finally {
imgQuerySelectorSpy.mockRestore();
pictureQuerySelectorSpy.mockRestore();
}

View file

@ -7,7 +7,7 @@ describe('Local Autocompleter', () => {
let mockData: ArrayBuffer;
const defaultK = 5;
beforeAll(async() => {
beforeAll(async () => {
const mockDataPath = join(__dirname, 'autocomplete-compiled-v2.bin');
/**
* Read pre-generated binary autocomplete data
@ -78,9 +78,7 @@ describe('Local Autocompleter', () => {
it('should return namespaced suggestions without including namespace', () => {
const result = localAc.topK('test', defaultK);
expect(result).toEqual([
expect.objectContaining({ name: 'artist:test', imageCount: 1 }),
]);
expect(result).toEqual([expect.objectContaining({ name: 'artist:test', imageCount: 1 })]);
});
it('should return only the required number of suggestions', () => {

View file

@ -29,7 +29,7 @@ describe('Request utils', () => {
headers: {
'Content-Type': 'application/json',
'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'xmlhttprequest'
'x-requested-with': 'xmlhttprequest',
},
});
});
@ -46,12 +46,12 @@ describe('Request utils', () => {
headers: {
'Content-Type': 'application/json',
'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'xmlhttprequest'
'x-requested-with': 'xmlhttprequest',
},
body: JSON.stringify({
...mockBody,
_method: mockVerb
})
_method: mockVerb,
}),
});
});
});
@ -64,7 +64,7 @@ describe('Request utils', () => {
credentials: 'same-origin',
headers: {
'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'xmlhttprequest'
'x-requested-with': 'xmlhttprequest',
},
});
});

View file

@ -60,9 +60,11 @@ describe('Store utilities', () => {
},
};
const initialValueKeys = Object.keys(initialValues) as (keyof typeof initialValues)[];
setStorageValue(initialValueKeys.reduce((acc, key) => {
return { ...acc, [key]: JSON.stringify(initialValues[key]) };
}, {}));
setStorageValue(
initialValueKeys.reduce((acc, key) => {
return { ...acc, [key]: JSON.stringify(initialValues[key]) };
}, {}),
);
initialValueKeys.forEach((key, i) => {
const result = store.get(key);
@ -166,7 +168,11 @@ describe('Store utilities', () => {
expect(setItemSpy).toHaveBeenCalledTimes(2);
expect(setItemSpy).toHaveBeenNthCalledWith(1, mockKey, JSON.stringify(mockValue));
expect(setItemSpy).toHaveBeenNthCalledWith(2, mockKey + lastUpdatedSuffix, JSON.stringify(initialDateNow + mockMaxAge));
expect(setItemSpy).toHaveBeenNthCalledWith(
2,
mockKey + lastUpdatedSuffix,
JSON.stringify(initialDateNow + mockMaxAge),
);
});
});

View file

@ -57,7 +57,7 @@ describe('Tag utilities', () => {
});
describe('getHiddenTags', () => {
it('should get a single hidden tag\'s information', () => {
it("should get a single hidden tag's information", () => {
window.booru.hiddenTagList = [1, 1];
const result = getHiddenTags();
@ -72,12 +72,7 @@ describe('Tag utilities', () => {
const result = getHiddenTags();
expect(result).toHaveLength(4);
expect(result).toEqual([
mockTagInfo[3],
mockTagInfo[2],
mockTagInfo[1],
mockTagInfo[4],
]);
expect(result).toEqual([mockTagInfo[3], mockTagInfo[2], mockTagInfo[1], mockTagInfo[4]]);
});
});
@ -91,7 +86,7 @@ describe('Tag utilities', () => {
expect(result).toHaveLength(0);
});
it('should get a single spoilered tag\'s information', () => {
it("should get a single spoilered tag's information", () => {
window.booru.spoileredTagList = [1, 1];
window.booru.ignoredTagList = [];
window.booru.spoilerType = getEnabledSpoilerType();
@ -110,12 +105,7 @@ describe('Tag utilities', () => {
const result = getSpoileredTags();
expect(result).toHaveLength(4);
expect(result).toEqual([
mockTagInfo[2],
mockTagInfo[3],
mockTagInfo[1],
mockTagInfo[4],
]);
expect(result).toEqual([mockTagInfo[2], mockTagInfo[3], mockTagInfo[1], mockTagInfo[4]]);
});
it('should omit ignored tags from the list', () => {
@ -125,10 +115,7 @@ describe('Tag utilities', () => {
const result = getSpoileredTags();
expect(result).toHaveLength(2);
expect(result).toEqual([
mockTagInfo[1],
mockTagInfo[4],
]);
expect(result).toEqual([mockTagInfo[1], mockTagInfo[4]]);
});
});
@ -140,10 +127,7 @@ describe('Tag utilities', () => {
const result = imageHitsTags(mockImage, [mockTagInfo[1], mockTagInfo[2], mockTagInfo[3], mockTagInfo[4]]);
expect(result).toHaveLength(mockImageTags.length);
expect(result).toEqual([
mockTagInfo[1],
mockTagInfo[4],
]);
expect(result).toEqual([mockTagInfo[1], mockTagInfo[4]]);
});
it('should return empty array if data attribute is missing', () => {
@ -174,12 +158,16 @@ describe('Tag utilities', () => {
it('should return the correct value for two tags', () => {
const result = displayTags([mockTagInfo[1], mockTagInfo[4]]);
expect(result).toEqual(`${mockTagInfo[1].name}<span title="${mockTagInfo[4].name}">, ${mockTagInfo[4].name}</span>`);
expect(result).toEqual(
`${mockTagInfo[1].name}<span title="${mockTagInfo[4].name}">, ${mockTagInfo[4].name}</span>`,
);
});
it('should return the correct value for three tags', () => {
const result = displayTags([mockTagInfo[1], mockTagInfo[4], mockTagInfo[3]]);
expect(result).toEqual(`${mockTagInfo[1].name}<span title="${mockTagInfo[4].name}, ${mockTagInfo[3].name}">, ${mockTagInfo[4].name}, ${mockTagInfo[3].name}</span>`);
expect(result).toEqual(
`${mockTagInfo[1].name}<span title="${mockTagInfo[4].name}, ${mockTagInfo[3].name}">, ${mockTagInfo[4].name}, ${mockTagInfo[3].name}</span>`,
);
});
it('should escape HTML in the tag name', () => {

View file

@ -5,14 +5,20 @@ type PhilomenaInputElements = HTMLTextAreaElement | HTMLInputElement | HTMLButto
/**
* Get the first matching element
*/
export function $<E extends Element = Element>(selector: string, context: Pick<Document, 'querySelector'> = document): E | null {
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[] {
export function $$<E extends Element = Element>(
selector: string,
context: Pick<Document, 'querySelectorAll'> = document,
): E[] {
const elements = context.querySelectorAll<E>(selector);
return [...elements];
@ -52,7 +58,10 @@ 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] {
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) {
@ -65,7 +74,10 @@ export function makeEl<Tag extends keyof HTMLElementTagNameMap>(tag: Tag, attr?:
return el;
}
export function onLeftClick(callback: (e: MouseEvent) => boolean | void, context: Pick<GlobalEventHandlers, 'addEventListener' | 'removeEventListener'> = document): VoidFunction {
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);
};
@ -80,22 +92,17 @@ export function onLeftClick(callback: (e: MouseEvent) => boolean | void, context
export function whenReady(callback: VoidFunction): void {
if (document.readyState !== 'loading') {
callback();
}
else {
} 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;');
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, '\\"');
return css.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
export function findFirstTextNode<N extends Node>(of: Node): N {

View file

@ -47,8 +47,7 @@ function drop(event: DragEvent, target: HTMLElement) {
if (event.clientX < detX) {
target.insertAdjacentElement('beforebegin', dragSrcEl);
}
else {
} else {
target.insertAdjacentElement('afterend', dragSrcEl);
}
}
@ -63,12 +62,12 @@ function dragEnd(event: DragEvent, target: HTMLElement) {
export function initDraggables() {
const draggableSelector = '.drag-container [draggable]';
delegate(document, 'dragstart', { [draggableSelector]: dragStart});
delegate(document, 'dragover', { [draggableSelector]: dragOver});
delegate(document, 'dragenter', { [draggableSelector]: dragEnter});
delegate(document, 'dragleave', { [draggableSelector]: dragLeave});
delegate(document, 'dragend', { [draggableSelector]: dragEnd});
delegate(document, 'drop', { [draggableSelector]: drop});
delegate(document, 'dragstart', { [draggableSelector]: dragStart });
delegate(document, 'dragover', { [draggableSelector]: dragOver });
delegate(document, 'dragenter', { [draggableSelector]: dragEnter });
delegate(document, 'dragleave', { [draggableSelector]: dragLeave });
delegate(document, 'dragend', { [draggableSelector]: dragEnd });
delegate(document, 'drop', { [draggableSelector]: drop });
}
export function clearDragSource() {

View file

@ -3,16 +3,16 @@
import '../../types/ujs';
export interface PhilomenaAvailableEventsMap {
dragstart: DragEvent,
dragover: DragEvent,
dragenter: DragEvent,
dragleave: DragEvent,
dragend: DragEvent,
drop: DragEvent,
click: MouseEvent,
submit: Event,
reset: Event,
fetchcomplete: FetchcompleteEvent
dragstart: DragEvent;
dragover: DragEvent;
dragenter: DragEvent;
dragleave: DragEvent;
dragend: DragEvent;
drop: DragEvent;
click: MouseEvent;
submit: Event;
reset: Event;
fetchcomplete: FetchcompleteEvent;
}
export interface PhilomenaEventElement {
@ -20,7 +20,7 @@ export interface PhilomenaEventElement {
type: K,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
listener: (this: Document | HTMLElement, ev: PhilomenaAvailableEventsMap[K]) => any,
options?: boolean | AddEventListenerOptions | undefined
options?: boolean | AddEventListenerOptions | undefined,
): void;
}
@ -30,19 +30,23 @@ export function fire<El extends Element, D>(el: El, event: string, detail: D) {
export function on<K extends keyof PhilomenaAvailableEventsMap>(
node: PhilomenaEventElement,
event: K, selector: string, func: ((e: PhilomenaAvailableEventsMap[K], target: Element) => boolean)
event: K,
selector: string,
func: (e: PhilomenaAvailableEventsMap[K], target: Element) => boolean,
) {
delegate(node, event, { [selector]: func });
}
export function leftClick<E extends MouseEvent, Target extends EventTarget>(func: (e: E, t: Target) => void) {
return (event: E, target: Target) => { if (event.button === 0) return func(event, target); };
return (event: E, target: Target) => {
if (event.button === 0) return func(event, target);
};
}
export function delegate<K extends keyof PhilomenaAvailableEventsMap, Target extends Element>(
node: PhilomenaEventElement,
event: K,
selectors: Record<string, ((e: PhilomenaAvailableEventsMap[K], target: Target) => void | boolean)>
selectors: Record<string, (e: PhilomenaAvailableEventsMap[K], target: Target) => void | boolean>,
) {
node.addEventListener(event, e => {
for (const selector in selectors) {

View file

@ -53,8 +53,7 @@ export function showThumb(img: HTMLDivElement) {
if (uris[size].indexOf('.webm') !== -1) {
overlay.classList.remove('hidden');
overlay.innerHTML = 'WebM';
}
else {
} else {
overlay.classList.add('hidden');
}
@ -118,7 +117,9 @@ export function spoilerThumb(img: HTMLDivElement, spoilerUri: string, reason: st
switch (window.booru.spoilerType) {
case 'click':
img.addEventListener('click', event => { if (showThumb(img)) event.preventDefault(); });
img.addEventListener('click', event => {
if (showThumb(img)) event.preventDefault();
});
img.addEventListener('mouseleave', () => hideThumb(img, spoilerUri, reason));
break;
case 'hover':

View file

@ -70,7 +70,7 @@ export class LocalAutocompleter {
associations.push(this.view.getUint32(location + 1 + nameLength + 1 + i * 4, true));
}
return [ name, associations ];
return [name, associations];
}
/**
@ -79,14 +79,14 @@ export class LocalAutocompleter {
getResultAt(i: number): [string, Result] {
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);
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, this.getResultAt(-imageCount - 1)[1]];
}
return [ name, { name, imageCount, associations } ];
return [name, { name, imageCount, associations }];
}
/**
@ -100,7 +100,11 @@ export class LocalAutocompleter {
/**
* Perform a binary search to fetch all results matching a condition.
*/
scanResults(getResult: (i: number) => [string, Result], compare: (name: string) => number, results: Record<string, Result>) {
scanResults(
getResult: (i: number) => [string, Result],
compare: (name: string) => number,
results: Record<string, Result>,
) {
const unfilter = store.get('unfilter_tag_suggestions');
let min = 0;
@ -109,14 +113,13 @@ export class LocalAutocompleter {
const hiddenTags = window.booru.hiddenTagList;
while (min < max - 1) {
const med = min + (max - min) / 2 | 0;
const med = (min + (max - min) / 2) | 0;
const sortKey = getResult(med)[0];
if (compare(sortKey) >= 0) {
// too large, go left
max = med;
}
else {
} else {
// too small, go right
min = med;
}
@ -124,7 +127,7 @@ export class LocalAutocompleter {
// Scan forward until no more matches occur
while (min < this.numTags - 1) {
const [ sortKey, result ] = getResult(++min);
const [sortKey, result] = getResult(++min);
if (compare(sortKey) !== 0) {
break;
}

View file

@ -9,7 +9,7 @@ export function fetchJson(verb: HttpMethod, endpoint: string, body?: Record<stri
headers: {
'Content-Type': 'application/json',
'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'xmlhttprequest'
'x-requested-with': 'xmlhttprequest',
},
};
@ -26,7 +26,7 @@ export function fetchHtml(endpoint: string): Promise<Response> {
credentials: 'same-origin',
headers: {
'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'xmlhttprequest'
'x-requested-with': 'xmlhttprequest',
},
});
}

View file

@ -5,13 +5,11 @@
export const lastUpdatedSuffix = '__lastUpdated';
export default {
set(key: string, value: unknown) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
}
catch {
} catch {
return false;
}
},
@ -21,8 +19,7 @@ export default {
if (value === null) return null;
try {
return JSON.parse(value);
}
catch {
} catch {
return value as unknown as Value;
}
},
@ -31,8 +28,7 @@ export default {
try {
localStorage.removeItem(key);
return true;
}
catch {
} catch {
return false;
}
},
@ -61,5 +57,4 @@ export default {
return lastUpdatedTime === null || Date.now() > lastUpdatedTime;
},
};

View file

@ -57,8 +57,10 @@ export function imageHitsComplex(img: HTMLElement, matchComplex: AstMatcher) {
}
export function displayTags(tags: TagData[]): string {
const mainTag = tags[0], otherTags = tags.slice(1);
let list = escapeHtml(mainTag.name), extras;
const mainTag = tags[0];
const otherTags = tags.slice(1);
let list = escapeHtml(mainTag.name);
let extras;
if (otherTags.length > 0) {
extras = otherTags.map(tag => escapeHtml(tag.name)).join(', ');

View file

@ -2,40 +2,39 @@
* Functions to execute when the DOM is ready
*/
import { whenReady } from './utils/dom';
import { whenReady } from './utils/dom';
import { listenAutocomplete } from './autocomplete';
import { loadBooruData } from './booru';
import { registerEvents } from './boorujs';
import { setupBurgerMenu } from './burger';
import { bindCaptchaLinks } from './captcha';
import { setupComments } from './comment';
import { setupDupeReports } from './duplicate_reports';
import { setSesCookie } from './fp';
import { setupGalleryEditing } from './galleries';
import { listenAutocomplete } from './autocomplete';
import { loadBooruData } from './booru';
import { registerEvents } from './boorujs';
import { setupBurgerMenu } from './burger';
import { bindCaptchaLinks } from './captcha';
import { setupComments } from './comment';
import { setupDupeReports } from './duplicate_reports';
import { setSesCookie } from './fp';
import { setupGalleryEditing } from './galleries';
import { initImagesClientside } from './imagesclientside';
import { bindImageTarget } from './image_expansion';
import { setupEvents } from './misc';
import { setupNotifications } from './notifications';
import { setupPreviews } from './preview';
import { setupQuickTag } from './quick-tag';
import { initializeListener } from './resizablemedia';
import { setupSettings } from './settings';
import { listenForKeys } from './shortcuts';
import { initTagDropdown } from './tags';
import { setupTagListener } from './tagsinput';
import { setupTagEvents } from './tagsmisc';
import { setupTimestamps } from './timeago';
import { setupImageUpload } from './upload';
import { setupSearch } from './search';
import { setupToolbar } from './markdowntoolbar';
import { hideStaffTools } from './staffhider';
import { pollOptionCreator } from './poll';
import { warnAboutPMs } from './pmwarning';
import { imageSourcesCreator } from './sources';
import { bindImageTarget } from './image_expansion';
import { setupEvents } from './misc';
import { setupNotifications } from './notifications';
import { setupPreviews } from './preview';
import { setupQuickTag } from './quick-tag';
import { initializeListener } from './resizablemedia';
import { setupSettings } from './settings';
import { listenForKeys } from './shortcuts';
import { initTagDropdown } from './tags';
import { setupTagListener } from './tagsinput';
import { setupTagEvents } from './tagsmisc';
import { setupTimestamps } from './timeago';
import { setupImageUpload } from './upload';
import { setupSearch } from './search';
import { setupToolbar } from './markdowntoolbar';
import { hideStaffTools } from './staffhider';
import { pollOptionCreator } from './poll';
import { warnAboutPMs } from './pmwarning';
import { imageSourcesCreator } from './sources';
whenReady(() => {
loadBooruData();
listenAutocomplete();
registerEvents();
@ -65,5 +64,4 @@ whenReady(() => {
pollOptionCreator();
warnAboutPMs();
imageSourcesCreator();
});

216
assets/package-lock.json generated
View file

@ -14,7 +14,7 @@
"normalize-scss": "^8.0.0",
"sass": "^1.75.0",
"typescript": "^5.4",
"typescript-eslint": "8.0.0-alpha.30",
"typescript-eslint": "8.0.0-alpha.39",
"vite": "^5.2"
},
"devDependencies": {
@ -23,8 +23,11 @@
"@types/chai-dom": "^1.11.3",
"@vitest/coverage-v8": "^1.6.0",
"chai": "^5",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-vitest": "^0.5.4",
"jsdom": "^24.1.0",
"prettier": "^3.3.2",
"vitest": "^1.6.0",
"vitest-fetch-mock": "^0.2.2"
}
@ -807,6 +810,18 @@
"node": ">= 8"
}
},
"node_modules/@pkgr/core": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz",
"integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==",
"dev": true,
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz",
@ -1205,15 +1220,15 @@
"integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.0.0-alpha.30",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.0-alpha.30.tgz",
"integrity": "sha512-2CBUupdkfbE3eATph4QeZejvT+M+1bVur+zXlVx09WN31phap51ps/qemeclnCbGEz6kTgBDmScrr9XmmF8/Pg==",
"version": "8.0.0-alpha.39",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.0-alpha.39.tgz",
"integrity": "sha512-ILv1vDA8M9ah1vzYpnOs4UOLRdB63Ki/rsxedVikjMLq68hFfpsDR25bdMZ4RyUkzLJwOhcg3Jujm/C1nupXKA==",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.0.0-alpha.30",
"@typescript-eslint/type-utils": "8.0.0-alpha.30",
"@typescript-eslint/utils": "8.0.0-alpha.30",
"@typescript-eslint/visitor-keys": "8.0.0-alpha.30",
"@typescript-eslint/scope-manager": "8.0.0-alpha.39",
"@typescript-eslint/type-utils": "8.0.0-alpha.39",
"@typescript-eslint/utils": "8.0.0-alpha.39",
"@typescript-eslint/visitor-keys": "8.0.0-alpha.39",
"graphemer": "^1.4.0",
"ignore": "^5.3.1",
"natural-compare": "^1.4.0",
@ -1237,14 +1252,14 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.0.0-alpha.30",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.0.0-alpha.30.tgz",
"integrity": "sha512-tAYgFmgXU1MlCK3nbblUvJlDSibBvxtAQXGrF3IG0KmnRza9FXILZifHWL0rrwacDn40K53K607Fk2QkMjiGgw==",
"version": "8.0.0-alpha.39",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.0.0-alpha.39.tgz",
"integrity": "sha512-5k+pwV91plJojHgZkWlq4/TQdOrnEaeSvt48V0m8iEwdMJqX/63BXYxy8BUOSghWcjp05s73vy9HJjovAKmHkQ==",
"dependencies": {
"@typescript-eslint/scope-manager": "8.0.0-alpha.30",
"@typescript-eslint/types": "8.0.0-alpha.30",
"@typescript-eslint/typescript-estree": "8.0.0-alpha.30",
"@typescript-eslint/visitor-keys": "8.0.0-alpha.30",
"@typescript-eslint/scope-manager": "8.0.0-alpha.39",
"@typescript-eslint/types": "8.0.0-alpha.39",
"@typescript-eslint/typescript-estree": "8.0.0-alpha.39",
"@typescript-eslint/visitor-keys": "8.0.0-alpha.39",
"debug": "^4.3.4"
},
"engines": {
@ -1264,12 +1279,12 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.0.0-alpha.30",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.0.0-alpha.30.tgz",
"integrity": "sha512-FGW/iPWGyPFamAVZ60oCAthMqQrqafUGebF8UKuq/ha+e9SVG6YhJoRzurlQXOVf8dHfOhJ0ADMXyFnMc53clg==",
"version": "8.0.0-alpha.39",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.0.0-alpha.39.tgz",
"integrity": "sha512-HCBlKQROY+JIgWolucdFMj1W3VUnnIQTdxAhxJTAj3ix2nASmvKIFgrdo5KQMrXxQj6tC4l3zva10L+s0dUIIw==",
"dependencies": {
"@typescript-eslint/types": "8.0.0-alpha.30",
"@typescript-eslint/visitor-keys": "8.0.0-alpha.30"
"@typescript-eslint/types": "8.0.0-alpha.39",
"@typescript-eslint/visitor-keys": "8.0.0-alpha.39"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1280,12 +1295,12 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.0.0-alpha.30",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.0.0-alpha.30.tgz",
"integrity": "sha512-FrnhlCKEKZKRbpDviHkIU9tayIUGTOfa+SjvrRv6p/AJIUv6QT8oRboRjLH/cCuwUEbM0k5UtRWYug4albHUqQ==",
"version": "8.0.0-alpha.39",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.0.0-alpha.39.tgz",
"integrity": "sha512-alO13fRU6yVeJbwl9ESI3AYhq5dQdz3Dpd0I5B4uezs2lvgYp44dZsj5hWyPz/kL7JFEsjbn+4b/CZA0OQJzjA==",
"dependencies": {
"@typescript-eslint/typescript-estree": "8.0.0-alpha.30",
"@typescript-eslint/utils": "8.0.0-alpha.30",
"@typescript-eslint/typescript-estree": "8.0.0-alpha.39",
"@typescript-eslint/utils": "8.0.0-alpha.39",
"debug": "^4.3.4",
"ts-api-utils": "^1.3.0"
},
@ -1303,9 +1318,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.0.0-alpha.30",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.0-alpha.30.tgz",
"integrity": "sha512-4WzLlw27SO9pK9UFj/Hu7WGo8WveT0SEiIpFVsV2WwtQmLps6kouwtVCB8GJPZKJyurhZhcqCoQVQFmpv441Vg==",
"version": "8.0.0-alpha.39",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.0-alpha.39.tgz",
"integrity": "sha512-yINN7j0/+S1VGSp0IgH52oQvUx49vkOug6xbrDA/9o+U55yCAQKSvYWvzYjNa+SZE3hXI0zwvYtMVsIAAMmKIQ==",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@ -1315,12 +1330,12 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.0.0-alpha.30",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.0-alpha.30.tgz",
"integrity": "sha512-WSXbc9ZcXI+7yC+6q95u77i8FXz6HOLsw3ST+vMUlFy1lFbXyFL/3e6HDKQCm2Clt0krnoCPiTGvIn+GkYPn4Q==",
"version": "8.0.0-alpha.39",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.0-alpha.39.tgz",
"integrity": "sha512-S8gREuP8r8PCxGegeojeXntx0P50ul9YH7c7JYpbLIIsEPNr5f7UHlm+I1NUbL04CBin4kvZ60TG4eWr/KKN9A==",
"dependencies": {
"@typescript-eslint/types": "8.0.0-alpha.30",
"@typescript-eslint/visitor-keys": "8.0.0-alpha.30",
"@typescript-eslint/types": "8.0.0-alpha.39",
"@typescript-eslint/visitor-keys": "8.0.0-alpha.39",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@ -1350,9 +1365,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
"integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dependencies": {
"brace-expansion": "^2.0.1"
},
@ -1364,14 +1379,14 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.0.0-alpha.30",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.0-alpha.30.tgz",
"integrity": "sha512-rfhqfLqFyXhHNDwMnHiVGxl/Z2q/3guQ1jLlGQ0hi9Rb7inmwz42crM+NnLPR+2vEnwyw1P/g7fnQgQ3qvFx4g==",
"version": "8.0.0-alpha.39",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.0-alpha.39.tgz",
"integrity": "sha512-Nr2PrlfNhrNQTlFHlD7XJdTGw/Vt8qY44irk6bfjn9LxGdSG5e4c1R2UN6kvGMhhx20DBPbM7q3Z3r+huzmL1w==",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "8.0.0-alpha.30",
"@typescript-eslint/types": "8.0.0-alpha.30",
"@typescript-eslint/typescript-estree": "8.0.0-alpha.30"
"@typescript-eslint/scope-manager": "8.0.0-alpha.39",
"@typescript-eslint/types": "8.0.0-alpha.39",
"@typescript-eslint/typescript-estree": "8.0.0-alpha.39"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1385,11 +1400,11 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.0.0-alpha.30",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.0-alpha.30.tgz",
"integrity": "sha512-XZuNurZxBqmr6ZIRIwWFq7j5RZd6ZlkId/HZEWyfciK+CWoyOxSF9Pv2VXH9Rlu2ZG2PfbhLz2Veszl4Pfn7yA==",
"version": "8.0.0-alpha.39",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.0-alpha.39.tgz",
"integrity": "sha512-DVJ0UdhucZy+/1GlIy7FX2+CFhCeNAi4VwaEAe7u2UDenQr9/kGqvzx00UlpWibmEVDw4KsPOI7Aqa1+2Vqfmw==",
"dependencies": {
"@typescript-eslint/types": "8.0.0-alpha.30",
"@typescript-eslint/types": "8.0.0-alpha.39",
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
@ -2393,6 +2408,48 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-config-prettier": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
"dev": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
"peerDependencies": {
"eslint": ">=7.0.0"
}
},
"node_modules/eslint-plugin-prettier": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz",
"integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==",
"dev": true,
"dependencies": {
"prettier-linter-helpers": "^1.0.0",
"synckit": "^0.8.6"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint-plugin-prettier"
},
"peerDependencies": {
"@types/eslint": ">=8.0.0",
"eslint": ">=8.0.0",
"eslint-config-prettier": "*",
"prettier": ">=3.0.0"
},
"peerDependenciesMeta": {
"@types/eslint": {
"optional": true
},
"eslint-config-prettier": {
"optional": true
}
}
},
"node_modules/eslint-plugin-vitest": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/eslint-plugin-vitest/-/eslint-plugin-vitest-0.5.4.tgz",
@ -2679,6 +2736,12 @@
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"dev": true
},
"node_modules/fast-glob": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
@ -4056,6 +4119,33 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz",
"integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-linter-helpers": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
"integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
"dev": true,
"dependencies": {
"fast-diff": "^1.1.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@ -4455,6 +4545,22 @@
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="
},
"node_modules/synckit": {
"version": "0.8.8",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
"integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==",
"dev": true,
"dependencies": {
"@pkgr/core": "^0.1.0",
"tslib": "^2.6.2"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
}
},
"node_modules/test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
@ -4555,6 +4661,12 @@
"typescript": ">=4.2.0"
}
},
"node_modules/tslib": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==",
"dev": true
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -4587,13 +4699,13 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.0.0-alpha.30",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.0.0-alpha.30.tgz",
"integrity": "sha512-/vGhBMsK1TpadQh1eQ02c5pyiPGmKR9cVzX5C9plZ+LC0HPLpWoJbbTVfQN7BkIK7tUxDt2BFr3pFL5hDDrx7g==",
"version": "8.0.0-alpha.39",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.0.0-alpha.39.tgz",
"integrity": "sha512-bsuR1BVJfHr7sBh7Cca962VPIcP+5UWaIa/+6PpnFZ+qtASjGTxKWIF5dG2o73BX9NsyqQfvRWujb3M9CIoRXA==",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.0.0-alpha.30",
"@typescript-eslint/parser": "8.0.0-alpha.30",
"@typescript-eslint/utils": "8.0.0-alpha.30"
"@typescript-eslint/eslint-plugin": "8.0.0-alpha.39",
"@typescript-eslint/parser": "8.0.0-alpha.39",
"@typescript-eslint/utils": "8.0.0-alpha.39"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"

View file

@ -12,7 +12,6 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.2",
"@types/web": "^0.0.148",
"typescript-eslint": "8.0.0-alpha.30",
"autoprefixer": "^10.4.19",
"cross-env": "^7.0.3",
"eslint": "^9.4.0",
@ -20,6 +19,7 @@
"normalize-scss": "^8.0.0",
"sass": "^1.75.0",
"typescript": "^5.4",
"typescript-eslint": "8.0.0-alpha.39",
"vite": "^5.2"
},
"devDependencies": {
@ -28,8 +28,11 @@
"@types/chai-dom": "^1.11.3",
"@vitest/coverage-v8": "^1.6.0",
"chai": "^5",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-vitest": "^0.5.4",
"jsdom": "^24.1.0",
"prettier": "^3.3.2",
"vitest": "^1.6.0",
"vitest-fetch-mock": "^0.2.2"
}

View file

@ -8,7 +8,7 @@ export function fixEventListeners(t: EventTarget) {
eventListeners = {};
const oldAddEventListener = t.addEventListener;
t.addEventListener = function(type: string, listener: any, options: any): void {
t.addEventListener = function (type: string, listener: any, options: any): void {
eventListeners[type] = eventListeners[type] || [];
eventListeners[type].push(listener);
return oldAddEventListener(type, listener, options);

View file

@ -2,7 +2,9 @@ import { MockInstance } from 'vitest';
type MockStorageKeys = 'getItem' | 'setItem' | 'removeItem';
export function mockStorage<Keys extends MockStorageKeys>(options: Pick<Storage, Keys>): { [k in `${Keys}Spy`]: MockInstance } {
export function mockStorage<Keys extends MockStorageKeys>(
options: Pick<Storage, Keys>,
): { [k in `${Keys}Spy`]: MockInstance } {
const getItemSpy = 'getItem' in options ? vi.spyOn(Storage.prototype, 'getItem') : undefined;
const setItemSpy = 'setItem' in options ? vi.spyOn(Storage.prototype, 'setItem') : undefined;
const removeItemSpy = 'removeItem' in options ? vi.spyOn(Storage.prototype, 'removeItem') : undefined;
@ -33,18 +35,18 @@ type MockStorageImplApi = { [k in `${MockStorageKeys}Spy`]: MockInstance } & {
* Forces the mock storage back to its default (empty) state
* @param value
*/
clearStorage: VoidFunction,
clearStorage: VoidFunction;
/**
* Forces the mock storage to be in the specific state provided as the parameter
* @param value
*/
setStorageValue: (value: Record<string, string>) => void,
setStorageValue: (value: Record<string, string>) => void;
/**
* Forces the mock storage to throw an error for the duration of the provided function's execution,
* or in case a promise is returned by the function, until that promise is resolved.
*/
forceStorageError: <Args, Return>(func: (...args: Args[]) => Return | Promise<Return>) => void
}
forceStorageError: <Args, Return>(func: (...args: Args[]) => Return | Promise<Return>) => void;
};
export function mockStorageImpl(): MockStorageImplApi {
let shouldThrow = false;

View file

@ -20,7 +20,7 @@ window.booru = {
hiddenFilter: matchNone(),
spoileredFilter: matchNone(),
interactions: [],
tagsVersion: 5
tagsVersion: 5,
};
// https://github.com/jsdom/jsdom/issues/1721#issuecomment-1484202038
@ -30,6 +30,6 @@ Object.assign(globalThis, { URL, Blob });
// Prevents an error when calling `form.submit()` directly in
// the code that is being tested
HTMLFormElement.prototype.submit = function() {
HTMLFormElement.prototype.submit = function () {
fireEvent.submit(this);
};

View file

@ -2,7 +2,7 @@ export {};
declare global {
interface FetchcompleteEvent extends CustomEvent<Response> {
target: HTMLElement,
target: HTMLElement;
}
interface GlobalEventHandlersEventMap {

View file

@ -7,13 +7,14 @@ import { defineConfig, UserConfig, ConfigEnv } from 'vite';
export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
const isDev = command !== 'build' && mode !== 'test';
const themeNames =
fs.readdirSync(path.resolve(__dirname, 'css/themes/')).map(name => {
const m = name.match(/([-a-z]+).scss/);
const themeNames = fs.readdirSync(path.resolve(__dirname, 'css/themes/')).map(name => {
const m = name.match(/([-a-z]+).scss/);
if (m) { return m[1]; }
return null;
});
if (m) {
return m[1];
}
return null;
});
const themes = new Map();
@ -31,8 +32,8 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
resolve: {
alias: {
common: path.resolve(__dirname, 'css/common/'),
views: path.resolve(__dirname, 'css/views/')
}
views: path.resolve(__dirname, 'css/views/'),
},
},
build: {
target: ['es2016', 'chrome67', 'firefox62', 'edge18', 'safari12'],
@ -44,19 +45,19 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
rollupOptions: {
input: {
'js/app': './js/app.ts',
...Object.fromEntries(themes)
...Object.fromEntries(themes),
},
output: {
entryFileNames: '[name].js',
chunkFileNames: '[name].js',
assetFileNames: '[name][extname]'
}
}
assetFileNames: '[name][extname]',
},
},
},
css: {
postcss: {
plugins: [autoprefixer]
}
postcss: {
plugins: [autoprefixer],
},
},
test: {
globals: true,
@ -67,11 +68,7 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
coverage: {
reporter: ['text', 'html'],
include: ['js/**/*.{js,ts}'],
exclude: [
'node_modules/',
'.*\\.test\\.ts$',
'.*\\.d\\.ts$',
],
exclude: ['node_modules/', '.*\\.test\\.ts$', '.*\\.d\\.ts$'],
thresholds: {
statements: 0,
branches: 0,
@ -83,8 +80,8 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
functions: 100,
lines: 100,
},
}
}
}
},
},
},
};
});