diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml
index dfdfab46..25558653 100644
--- a/.github/workflows/elixir.yml
+++ b/.github/workflows/elixir.yml
@@ -14,8 +14,19 @@ jobs:
with:
path: |
_build
+ .cargo
deps
- key: ${{ runner.os }}-build-deps-${{ hashFiles('mix.lock') }}
+ key: ${{ runner.os }}-deps-2-${{ hashFiles('mix.lock') }}
+
+ - name: Enable caching
+ run: |
+ # Disable volumes so caching can take effect
+ sed -i -Ee 's/- app_[a-z]+_data:.*$//g' docker-compose.yml
+
+ # Make ourselves the owner
+ echo "RUN addgroup -g $(id -g) -S appgroup && adduser -u $(id -u) -S appuser -G appgroup" >> docker/app/Dockerfile
+ echo "USER appuser" >> docker/app/Dockerfile
+ echo "RUN mix local.hex --force && mix local.rebar --force" >> docker/app/Dockerfile
- run: docker compose pull
- run: docker compose build
@@ -27,6 +38,18 @@ jobs:
run: |
docker compose run app mix sobelow --config
docker compose run app mix deps.audit
+
+ - name: Dialyzer
+ run: |
+ docker compose run app mix dialyzer
+
+ typos:
+ name: 'Check for spelling errors'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: crate-ci/typos@master
+
lint-and-test:
name: 'JavaScript Linting and Unit Tests'
runs-on: ubuntu-latest
diff --git a/.typos.toml b/.typos.toml
new file mode 100644
index 00000000..898ad2e4
--- /dev/null
+++ b/.typos.toml
@@ -0,0 +1,10 @@
+[default]
+extend-ignore-re = [
+ # Ignore development secret key. Production secret key should
+ # be in environment files and not checked into source control.
+ ".*secret_key_base.*",
+
+ # Key constraints with encoded names
+ "fk_rails_[a-f0-9]+"
+]
+
diff --git a/assets/js/__tests__/imagesclientside.spec.ts b/assets/js/__tests__/imagesclientside.spec.ts
new file mode 100644
index 00000000..3f3feb88
--- /dev/null
+++ b/assets/js/__tests__/imagesclientside.spec.ts
@@ -0,0 +1,161 @@
+import { filterNode, initImagesClientside } from '../imagesclientside';
+import { parseSearch } from '../match_query';
+import { matchNone } from '../query/boolean';
+import { assertNotNull } from '../utils/assert';
+import { $ } from '../utils/dom';
+
+describe('filterNode', () => {
+ beforeEach(() => {
+ window.booru.hiddenTagList = [];
+ window.booru.spoileredTagList = [];
+ window.booru.ignoredTagList = [];
+ window.booru.imagesWithDownvotingDisabled = [];
+
+ window.booru.hiddenFilter = matchNone();
+ window.booru.spoileredFilter = matchNone();
+ });
+
+ function makeMediaContainer() {
+ const element = document.createElement('div');
+ element.innerHTML = `
+
+
+
+
+ `;
+ return [ element, assertNotNull($('.js-spoiler-info-overlay', element)) ];
+ }
+
+ it('should show image media boxes not matching any filter', () => {
+ const [ container, spoilerOverlay ] = makeMediaContainer();
+
+ filterNode(container);
+ expect(spoilerOverlay).not.toContainHTML('(Complex Filter)');
+ expect(spoilerOverlay).not.toContainHTML('(unknown tag)');
+ expect(window.booru.imagesWithDownvotingDisabled).not.toContain('1');
+ });
+
+ it('should spoiler media boxes spoilered by a tag filter', () => {
+ const [ container, spoilerOverlay ] = makeMediaContainer();
+ window.booru.spoileredTagList = [1];
+
+ filterNode(container);
+ expect(spoilerOverlay).toContainHTML('(unknown tag)');
+ expect(window.booru.imagesWithDownvotingDisabled).toContain('1');
+ });
+
+ it('should spoiler media boxes spoilered by a complex filter', () => {
+ const [ container, spoilerOverlay ] = makeMediaContainer();
+ window.booru.spoileredFilter = parseSearch('id:1');
+
+ filterNode(container);
+ expect(spoilerOverlay).toContainHTML('(Complex Filter)');
+ expect(window.booru.imagesWithDownvotingDisabled).toContain('1');
+ });
+
+ it('should hide media boxes hidden by a tag filter', () => {
+ const [ container, spoilerOverlay ] = makeMediaContainer();
+ window.booru.hiddenTagList = [1];
+
+ filterNode(container);
+ expect(spoilerOverlay).toContainHTML('[HIDDEN]');
+ expect(spoilerOverlay).toContainHTML('(unknown tag)');
+ expect(window.booru.imagesWithDownvotingDisabled).toContain('1');
+ });
+
+ it('should hide media boxes hidden by a complex filter', () => {
+ const [ container, spoilerOverlay ] = makeMediaContainer();
+ window.booru.hiddenFilter = parseSearch('id:1');
+
+ filterNode(container);
+ expect(spoilerOverlay).toContainHTML('[HIDDEN]');
+ expect(spoilerOverlay).toContainHTML('(Complex Filter)');
+ expect(window.booru.imagesWithDownvotingDisabled).toContain('1');
+ });
+
+ function makeImageBlock(): HTMLElement[] {
+ const element = document.createElement('div');
+ element.innerHTML = `
+
+
+
+
+
+
+
+
+
+ `;
+ return [
+ element,
+ assertNotNull($('.image-filtered', element)),
+ assertNotNull($('.image-show', element)),
+ assertNotNull($('.filter-explanation', element))
+ ];
+ }
+
+ it('should show image blocks not matching any filter', () => {
+ const [ container, imageFiltered, imageShow ] = makeImageBlock();
+
+ filterNode(container);
+ expect(imageFiltered).toHaveClass('hidden');
+ expect(imageShow).not.toHaveClass('hidden');
+ expect(window.booru.imagesWithDownvotingDisabled).not.toContain('1');
+ });
+
+ it('should spoiler image blocks spoilered by a tag filter', () => {
+ const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock();
+ window.booru.spoileredTagList = [1];
+
+ filterNode(container);
+ expect(imageFiltered).not.toHaveClass('hidden');
+ expect(imageShow).toHaveClass('hidden');
+ expect(filterExplanation).toContainHTML('spoilered by');
+ expect(filterExplanation).toContainHTML('(unknown tag)');
+ expect(window.booru.imagesWithDownvotingDisabled).toContain('1');
+ });
+
+ it('should spoiler image blocks spoilered by a complex filter', () => {
+ const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock();
+ window.booru.spoileredFilter = parseSearch('id:1');
+
+ filterNode(container);
+ expect(imageFiltered).not.toHaveClass('hidden');
+ expect(imageShow).toHaveClass('hidden');
+ expect(filterExplanation).toContainHTML('spoilered by');
+ expect(filterExplanation).toContainHTML('complex tag expression');
+ expect(window.booru.imagesWithDownvotingDisabled).toContain('1');
+ });
+
+ it('should hide image blocks hidden by a tag filter', () => {
+ const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock();
+ window.booru.hiddenTagList = [1];
+
+ filterNode(container);
+ expect(imageFiltered).not.toHaveClass('hidden');
+ expect(imageShow).toHaveClass('hidden');
+ expect(filterExplanation).toContainHTML('hidden by');
+ expect(filterExplanation).toContainHTML('(unknown tag)');
+ expect(window.booru.imagesWithDownvotingDisabled).toContain('1');
+ });
+
+ it('should hide image blocks hidden by a complex filter', () => {
+ const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock();
+ window.booru.hiddenFilter = parseSearch('id:1');
+
+ filterNode(container);
+ expect(imageFiltered).not.toHaveClass('hidden');
+ expect(imageShow).toHaveClass('hidden');
+ expect(filterExplanation).toContainHTML('hidden by');
+ expect(filterExplanation).toContainHTML('complex tag expression');
+ expect(window.booru.imagesWithDownvotingDisabled).toContain('1');
+ });
+
+});
+
+describe('initImagesClientside', () => {
+ it('should initialize the imagesWithDownvotingDisabled array', () => {
+ initImagesClientside();
+ expect(window.booru.imagesWithDownvotingDisabled).toEqual([]);
+ });
+});
diff --git a/assets/js/captcha.ts b/assets/js/captcha.ts
index eef2f0b2..44d0d9b6 100644
--- a/assets/js/captcha.ts
+++ b/assets/js/captcha.ts
@@ -1,15 +1,14 @@
+import { assertNotNull } from './utils/assert';
import { delegate, leftClick } from './utils/events';
import { clearEl, makeEl } from './utils/dom';
function insertCaptcha(_event: Event, target: HTMLInputElement) {
- const { parentElement, dataset: { sitekey } } = target;
-
- if (!parentElement) { return; }
+ 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'});
- frame.dataset.sitekey = sitekey;
+ frame.dataset.sitekey = target.dataset.sitekey;
clearEl(parentElement);
diff --git a/assets/js/duplicate_reports.ts b/assets/js/duplicate_reports.ts
index 12eb8c2c..55cdfeb1 100644
--- a/assets/js/duplicate_reports.ts
+++ b/assets/js/duplicate_reports.ts
@@ -2,9 +2,10 @@
* Interactive behavior for duplicate reports.
*/
+import { assertNotNull } from './utils/assert';
import { $, $$ } from './utils/dom';
-function setupDupeReports() {
+export function setupDupeReports() {
const onion = $('.onion-skin__image');
const slider = $('.onion-skin__slider');
const swipe = $('.swipe__image');
@@ -30,16 +31,12 @@ function setupSwipe(swipe: SVGSVGElement) {
}
function setupOnionSkin(onion: SVGSVGElement, slider: HTMLInputElement) {
- const target = $('#target', onion);
+ const target = assertNotNull($('#target', onion));
function setOpacity() {
- if (target) {
- target.setAttribute('opacity', slider.value);
- }
+ target.setAttribute('opacity', slider.value);
}
setOpacity();
slider.addEventListener('input', setOpacity);
}
-
-export { setupDupeReports };
diff --git a/assets/js/fp.ts b/assets/js/fp.ts
index 631cc26f..65f0c583 100644
--- a/assets/js/fp.ts
+++ b/assets/js/fp.ts
@@ -1,36 +1,39 @@
/**
* FP version 4
*
- * Not reliant on deprecated properties,
- * and potentially more accurate at what it's supposed to do.
+ * Not reliant on deprecated properties, and potentially
+ * more accurate at what it's supposed to do.
*/
-import { $ } from './utils/dom';
import store from './utils/store';
-interface RealKeyboard {
- getLayoutMap: () => Promise