diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml
index 25558653..a92aa72f 100644
--- a/.github/workflows/elixir.yml
+++ b/.github/workflows/elixir.yml
@@ -78,3 +78,6 @@ jobs:
- run: npm run test
working-directory: ./assets
+
+ - run: npm run build
+ working-directory: ./assets
\ No newline at end of file
diff --git a/assets/css/views/_notifications.scss b/assets/css/views/_notifications.scss
new file mode 100644
index 00000000..8c4f327e
--- /dev/null
+++ b/assets/css/views/_notifications.scss
@@ -0,0 +1,11 @@
+.notification-type-block:not(:last-child) {
+ margin-bottom: 20px;
+}
+
+.notification {
+ margin-bottom: 0;
+}
+
+.notification:not(:last-child) {
+ border-bottom: 0;
+}
diff --git a/assets/eslint.config.js b/assets/eslint.config.js
index c927efb6..2c7a6e63 100644
--- a/assets/eslint.config.js
+++ b/assets/eslint.config.js
@@ -125,7 +125,7 @@ export default tsEslint.config(
'no-irregular-whitespace': 2,
'no-iterator': 2,
'no-label-var': 2,
- 'no-labels': 2,
+ 'no-labels': [2, { allowSwitch: true, allowLoop: true }],
'no-lone-blocks': 2,
'no-lonely-if': 0,
'no-loop-func': 2,
diff --git a/assets/js/__tests__/input-duplicator.spec.ts b/assets/js/__tests__/input-duplicator.spec.ts
index 09115573..4e1e29b0 100644
--- a/assets/js/__tests__/input-duplicator.spec.ts
+++ b/assets/js/__tests__/input-duplicator.spec.ts
@@ -8,17 +8,17 @@ describe('Input duplicator functionality', () => {
document.documentElement.insertAdjacentHTML(
'beforeend',
`
`,
+ 3
+
+
+
+
+ `,
);
});
diff --git a/assets/js/__tests__/upload.spec.ts b/assets/js/__tests__/upload.spec.ts
index 9a1d7c4c..027241f8 100644
--- a/assets/js/__tests__/upload.spec.ts
+++ b/assets/js/__tests__/upload.spec.ts
@@ -25,6 +25,9 @@ const errorResponse = {
};
/* eslint-enable camelcase */
+const tagSets = ['', 'a tag', 'safe', 'one, two, three', 'safe, explicit', 'safe, explicit, three', 'safe, two, three'];
+const tagErrorCounts = [1, 2, 1, 1, 2, 1, 0];
+
describe('Image upload form', () => {
let mockPng: File;
let mockWebm: File;
@@ -58,18 +61,27 @@ describe('Image upload form', () => {
let scraperError: HTMLDivElement;
let fetchButton: HTMLButtonElement;
let tagsEl: HTMLTextAreaElement;
+ let taginputEl: HTMLDivElement;
let sourceEl: HTMLInputElement;
let descrEl: HTMLTextAreaElement;
+ let submitButton: HTMLButtonElement;
const assertFetchButtonIsDisabled = () => {
if (!fetchButton.hasAttribute('disabled')) throw new Error('fetchButton is not disabled');
};
+ const assertSubmitButtonIsDisabled = () => {
+ if (!submitButton.hasAttribute('disabled')) throw new Error('submitButton is not disabled');
+ };
+
+ const assertSubmitButtonIsEnabled = () => {
+ if (submitButton.hasAttribute('disabled')) throw new Error('submitButton is disabled');
+ };
+
beforeEach(() => {
document.documentElement.insertAdjacentHTML(
'beforeend',
- `
-
- `,
+
+
+
+ `,
);
form = assertNotNull($('form'));
@@ -89,9 +105,11 @@ describe('Image upload form', () => {
remoteUrl = assertNotUndefined($$('.js-scraper')[1]);
scraperError = assertNotUndefined($$('.js-scraper')[2]);
tagsEl = assertNotNull($('.js-image-tags-input'));
+ taginputEl = assertNotNull($('.js-taginput'));
sourceEl = assertNotNull($('.js-source-url'));
descrEl = assertNotNull($('.js-image-descr-input'));
fetchButton = assertNotNull($('#js-scraper-preview'));
+ submitButton = assertNotNull($('.actions > .button'));
setupImageUpload();
fetchMock.resetMocks();
@@ -195,4 +213,42 @@ describe('Image upload form', () => {
expect(scraperError.innerText).toEqual('Error 1 Error 2');
});
});
+
+ async function submitForm(frm: HTMLFormElement): Promise {
+ return new Promise(resolve => {
+ function onSubmit() {
+ frm.removeEventListener('submit', onSubmit);
+ resolve(true);
+ }
+
+ frm.addEventListener('submit', onSubmit);
+
+ if (!fireEvent.submit(frm)) {
+ frm.removeEventListener('submit', onSubmit);
+ resolve(false);
+ }
+ });
+ }
+
+ it('should prevent form submission if tag checks fail', async () => {
+ for (let i = 0; i < tagSets.length; i += 1) {
+ taginputEl.innerText = tagSets[i];
+
+ if (await submitForm(form)) {
+ // form submit succeeded
+ await waitFor(() => {
+ assertSubmitButtonIsDisabled();
+ const succeededUnloadEvent = new Event('beforeunload', { cancelable: true });
+ expect(fireEvent(window, succeededUnloadEvent)).toBe(true);
+ });
+ } else {
+ // form submit prevented
+ const frm = form;
+ await waitFor(() => {
+ assertSubmitButtonIsEnabled();
+ expect(frm.querySelectorAll('.help-block')).toHaveLength(tagErrorCounts[i]);
+ });
+ }
+ }
+ });
});
diff --git a/assets/js/autocomplete.js b/assets/js/autocomplete.js
deleted file mode 100644
index 1a95fb04..00000000
--- a/assets/js/autocomplete.js
+++ /dev/null
@@ -1,296 +0,0 @@
-/**
- * Autocomplete.
- */
-
-import { LocalAutocompleter } from './utils/local-autocompleter';
-import { handleError } from './utils/requests';
-import { getTermContexts } from './match_query';
-import store from './utils/store';
-
-const cache = {};
-/** @type {HTMLInputElement} */
-let inputField,
- /** @type {string} */
- originalTerm,
- /** @type {string} */
- originalQuery,
- /** @type {TermContext} */
- selectedTerm;
-
-function removeParent() {
- const parent = document.querySelector('.autocomplete');
- if (parent) parent.parentNode.removeChild(parent);
-}
-
-function removeSelected() {
- const selected = document.querySelector('.autocomplete__item--selected');
- if (selected) selected.classList.remove('autocomplete__item--selected');
-}
-
-function isSearchField() {
- return inputField && inputField.dataset.acMode === 'search';
-}
-
-function restoreOriginalValue() {
- inputField.value = isSearchField() ? originalQuery : originalTerm;
-}
-
-function applySelectedValue(selection) {
- if (!isSearchField()) {
- inputField.value = selection;
- return;
- }
-
- if (!selectedTerm) {
- return;
- }
-
- const [startIndex, endIndex] = selectedTerm[0];
- inputField.value = originalQuery.slice(0, startIndex) + selection + originalQuery.slice(endIndex);
- inputField.setSelectionRange(startIndex + selection.length, startIndex + selection.length);
- inputField.focus();
-}
-
-function changeSelected(firstOrLast, current, sibling) {
- 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
- restoreOriginalValue();
- removeSelected();
- } else if (firstOrLast) {
- // if no item in the list is selected, select the first or last
- firstOrLast.classList.add('autocomplete__item--selected');
- }
-}
-
-function isSelectionOutsideCurrentTerm() {
- const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd);
- const [startIndex, endIndex] = selectedTerm[0];
-
- return startIndex > selectionIndex || endIndex < selectionIndex;
-}
-
-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');
-
- 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
- requestAnimationFrame(() => {
- if (isSelectionOutsideCurrentTerm()) removeParent();
- });
- }
- }
-
- 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
- const newSelected = document.querySelector('.autocomplete__item--selected');
- if (newSelected) applySelectedValue(newSelected.dataset.value);
- event.preventDefault();
- }
-}
-
-function createItem(list, suggestion) {
- const item = document.createElement('li');
- item.className = 'autocomplete__item';
-
- item.textContent = suggestion.label;
- item.dataset.value = suggestion.value;
-
- item.addEventListener('mouseover', () => {
- removeSelected();
- item.classList.add('autocomplete__item--selected');
- });
-
- item.addEventListener('mouseout', () => {
- removeSelected();
- });
-
- item.addEventListener('click', () => {
- applySelectedValue(item.dataset.value);
- inputField.dispatchEvent(
- new CustomEvent('autocomplete', {
- detail: {
- type: 'click',
- label: suggestion.label,
- value: suggestion.value,
- },
- }),
- );
- });
-
- list.appendChild(item);
-}
-
-function createList(suggestions) {
- const parent = document.querySelector('.autocomplete'),
- list = document.createElement('ul');
- list.className = 'autocomplete__list';
-
- suggestions.forEach(suggestion => createItem(list, suggestion));
-
- parent.appendChild(list);
-}
-
-function createParent() {
- const parent = document.createElement('div');
- parent.className = 'autocomplete';
-
- // Position the parent below the inputfield
- parent.style.position = 'absolute';
- parent.style.left = `${inputField.offsetLeft}px`;
- // Take the inputfield offset, add its height and subtract the amount by which the parent element has scrolled
- parent.style.top = `${inputField.offsetTop + inputField.offsetHeight - inputField.parentNode.scrollTop}px`;
-
- // We append the parent at the end of body
- document.body.appendChild(parent);
-}
-
-function showAutocomplete(suggestions, fetchedTerm, targetInput) {
- // Remove old autocomplete suggestions
- removeParent();
-
- // Save suggestions in cache
- cache[fetchedTerm] = suggestions;
-
- // If the input target is not empty, still visible, and suggestions were found
- if (targetInput.value && targetInput.style.display !== 'none' && suggestions.length) {
- createParent();
- createList(suggestions);
- inputField.addEventListener('keydown', keydownHandler);
- }
-}
-
-function getSuggestions(term) {
- // In case source URL was not given at all, do not try sending the request.
- if (!inputField.dataset.acSource) return [];
- return fetch(`${inputField.dataset.acSource}${term}`).then(response => response.json());
-}
-
-function getSelectedTerm() {
- if (!inputField || !originalQuery) {
- return null;
- }
-
- const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd);
- const terms = getTermContexts(originalQuery);
-
- return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex);
-}
-
-function toggleSearchAutocomplete() {
- const enable = store.get('enable_search_ac');
-
- for (const searchField of document.querySelectorAll('input[data-ac-mode=search]')) {
- if (enable) {
- searchField.autocomplete = 'off';
- } else {
- searchField.removeAttribute('data-ac');
- searchField.autocomplete = 'on';
- }
- }
-}
-
-function listenAutocomplete() {
- let timeout;
-
- /** @type {LocalAutocompleter} */
- let localAc = null;
- let localFetched = false;
-
- document.addEventListener('focusin', fetchLocalAutocomplete);
-
- document.addEventListener('input', event => {
- removeParent();
- fetchLocalAutocomplete(event);
- window.clearTimeout(timeout);
-
- if (localAc !== null && 'ac' in event.target.dataset) {
- inputField = event.target;
- let suggestionsCount = 5;
-
- if (isSearchField()) {
- originalQuery = inputField.value;
- selectedTerm = getSelectedTerm();
- suggestionsCount = 10;
-
- // We don't need to run auto-completion if user is not selecting tag at all
- if (!selectedTerm) {
- return;
- }
-
- originalTerm = selectedTerm[1].toLowerCase();
- } else {
- originalTerm = `${inputField.value}`.toLowerCase();
- }
-
- const suggestions = localAc
- .topK(originalTerm, suggestionsCount)
- .map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name }));
-
- if (suggestions.length) {
- return showAutocomplete(suggestions, originalTerm, event.target);
- }
- }
-
- // Use a timeout to delay requests until the user has stopped typing
- timeout = window.setTimeout(() => {
- inputField = event.target;
- originalTerm = inputField.value;
-
- const fetchedTerm = inputField.value;
- const { ac, acMinLength, acSource } = inputField.dataset;
-
- if (ac && acSource && fetchedTerm.length >= acMinLength) {
- if (cache[fetchedTerm]) {
- showAutocomplete(cache[fetchedTerm], fetchedTerm, event.target);
- } else {
- // inputField could get overwritten while the suggestions are being fetched - use event.target
- getSuggestions(fetchedTerm).then(suggestions => {
- if (fetchedTerm === event.target.value) {
- showAutocomplete(suggestions, fetchedTerm, event.target);
- }
- });
- }
- }
- }, 300);
- });
-
- // If there's a click outside the inputField, remove autocomplete
- document.addEventListener('click', event => {
- if (event.target && event.target !== inputField) removeParent();
- if (event.target === inputField && isSearchField() && isSelectionOutsideCurrentTerm()) removeParent();
- });
-
- function fetchLocalAutocomplete(event) {
- if (!localFetched && event.target.dataset && 'ac' in event.target.dataset) {
- const now = new Date();
- const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`;
-
- localFetched = true;
-
- fetch(`/autocomplete/compiled?vsn=2&key=${cacheKey}`, { credentials: 'omit', cache: 'force-cache' })
- .then(handleError)
- .then(resp => resp.arrayBuffer())
- .then(buf => {
- localAc = new LocalAutocompleter(buf);
- });
- }
- }
-
- toggleSearchAutocomplete();
-}
-
-export { listenAutocomplete };
diff --git a/assets/js/autocomplete.ts b/assets/js/autocomplete.ts
new file mode 100644
index 00000000..489392c3
--- /dev/null
+++ b/assets/js/autocomplete.ts
@@ -0,0 +1,230 @@
+/**
+ * Autocomplete.
+ */
+
+import { LocalAutocompleter } from './utils/local-autocompleter';
+import { getTermContexts } from './match_query';
+import store from './utils/store';
+import { TermContext } from './query/lex';
+import { $$ } from './utils/dom';
+import { fetchLocalAutocomplete, fetchSuggestions, SuggestionsPopup, TermSuggestion } from './utils/suggestions';
+
+let inputField: HTMLInputElement | null = null,
+ originalTerm: string | undefined,
+ originalQuery: string | undefined,
+ selectedTerm: TermContext | null = null;
+
+const popup = new SuggestionsPopup();
+
+function isSearchField(targetInput: HTMLElement): boolean {
+ return targetInput && targetInput.dataset.acMode === 'search';
+}
+
+function restoreOriginalValue() {
+ if (!inputField) return;
+
+ if (isSearchField(inputField) && originalQuery) {
+ inputField.value = originalQuery;
+ }
+
+ if (originalTerm) {
+ inputField.value = originalTerm;
+ }
+}
+
+function applySelectedValue(selection: string) {
+ if (!inputField) return;
+
+ if (!isSearchField(inputField)) {
+ inputField.value = selection;
+ return;
+ }
+
+ if (selectedTerm && originalQuery) {
+ const [startIndex, endIndex] = selectedTerm[0];
+ inputField.value = originalQuery.slice(0, startIndex) + selection + originalQuery.slice(endIndex);
+ inputField.setSelectionRange(startIndex + selection.length, startIndex + selection.length);
+ inputField.focus();
+ }
+}
+
+function isSelectionOutsideCurrentTerm(): boolean {
+ if (!inputField || !selectedTerm) return true;
+ if (inputField.selectionStart === null || inputField.selectionEnd === null) return true;
+
+ const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd);
+ const [startIndex, endIndex] = selectedTerm[0];
+
+ return startIndex > selectionIndex || endIndex < selectionIndex;
+}
+
+function keydownHandler(event: KeyboardEvent) {
+ if (inputField !== event.currentTarget) return;
+
+ if (inputField && isSearchField(inputField)) {
+ // Prevent submission of the search field when Enter was hit
+ if (popup.selectedTerm && event.keyCode === 13) event.preventDefault(); // Enter
+
+ // Close autocompletion popup when text cursor is outside current tag
+ if (selectedTerm && (event.keyCode === 37 || event.keyCode === 39)) {
+ // ArrowLeft || ArrowRight
+ requestAnimationFrame(() => {
+ if (isSelectionOutsideCurrentTerm()) popup.hide();
+ });
+ }
+ }
+
+ if (!popup.isActive) return;
+
+ if (event.keyCode === 38) popup.selectPrevious(); // ArrowUp
+ if (event.keyCode === 40) popup.selectNext(); // ArrowDown
+ if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) popup.hide(); // Enter || Esc || Comma
+ if (event.keyCode === 38 || event.keyCode === 40) {
+ // ArrowUp || ArrowDown
+ if (popup.selectedTerm) {
+ applySelectedValue(popup.selectedTerm);
+ } else {
+ restoreOriginalValue();
+ }
+
+ event.preventDefault();
+ }
+}
+
+function findSelectedTerm(targetInput: HTMLInputElement, searchQuery: string): TermContext | null {
+ if (targetInput.selectionStart === null || targetInput.selectionEnd === null) return null;
+
+ const selectionIndex = Math.min(targetInput.selectionStart, targetInput.selectionEnd);
+ const terms = getTermContexts(searchQuery);
+
+ return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex) ?? null;
+}
+
+function toggleSearchAutocomplete() {
+ const enable = store.get('enable_search_ac');
+
+ for (const searchField of $$('input[data-ac-mode=search]')) {
+ if (enable) {
+ searchField.autocomplete = 'off';
+ } else {
+ searchField.removeAttribute('data-ac');
+ searchField.autocomplete = 'on';
+ }
+ }
+}
+
+function listenAutocomplete() {
+ let serverSideSuggestionsTimeout: number | undefined;
+
+ let localAc: LocalAutocompleter | null = null;
+ let isLocalLoading = false;
+
+ document.addEventListener('focusin', loadAutocompleteFromEvent);
+
+ document.addEventListener('input', event => {
+ popup.hide();
+ loadAutocompleteFromEvent(event);
+ window.clearTimeout(serverSideSuggestionsTimeout);
+
+ if (!(event.target instanceof HTMLInputElement)) return;
+
+ const targetedInput = event.target;
+
+ if (!targetedInput.dataset.ac) return;
+
+ targetedInput.addEventListener('keydown', keydownHandler);
+
+ if (localAc !== null) {
+ inputField = targetedInput;
+ let suggestionsCount = 5;
+
+ if (isSearchField(inputField)) {
+ originalQuery = inputField.value;
+ selectedTerm = findSelectedTerm(inputField, originalQuery);
+ suggestionsCount = 10;
+
+ // We don't need to run auto-completion if user is not selecting tag at all
+ if (!selectedTerm) {
+ return;
+ }
+
+ originalTerm = selectedTerm[1].toLowerCase();
+ } else {
+ originalTerm = `${inputField.value}`.toLowerCase();
+ }
+
+ const suggestions = localAc
+ .matchPrefix(originalTerm)
+ .topK(suggestionsCount)
+ .map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name }));
+
+ if (suggestions.length) {
+ popup.renderSuggestions(suggestions).showForField(targetedInput);
+ return;
+ }
+ }
+
+ const { acMinLength: minTermLength, acSource: endpointUrl } = targetedInput.dataset;
+
+ if (!endpointUrl) return;
+
+ // Use a timeout to delay requests until the user has stopped typing
+ serverSideSuggestionsTimeout = window.setTimeout(() => {
+ inputField = targetedInput;
+ originalTerm = inputField.value;
+
+ const fetchedTerm = inputField.value;
+
+ if (minTermLength && fetchedTerm.length < parseInt(minTermLength, 10)) return;
+
+ fetchSuggestions(endpointUrl, fetchedTerm).then(suggestions => {
+ // inputField could get overwritten while the suggestions are being fetched - use previously targeted input
+ if (fetchedTerm === targetedInput.value) {
+ popup.renderSuggestions(suggestions).showForField(targetedInput);
+ }
+ });
+ }, 300);
+ });
+
+ // If there's a click outside the inputField, remove autocomplete
+ document.addEventListener('click', event => {
+ if (event.target && event.target !== inputField) popup.hide();
+ if (inputField && event.target === inputField && isSearchField(inputField) && isSelectionOutsideCurrentTerm()) {
+ popup.hide();
+ }
+ });
+
+ function loadAutocompleteFromEvent(event: Event) {
+ if (!(event.target instanceof HTMLInputElement)) return;
+
+ if (!isLocalLoading && event.target.dataset.ac) {
+ isLocalLoading = true;
+
+ fetchLocalAutocomplete().then(autocomplete => {
+ localAc = autocomplete;
+ });
+ }
+ }
+
+ toggleSearchAutocomplete();
+
+ popup.onItemSelected((event: CustomEvent) => {
+ if (!event.detail || !inputField) return;
+
+ const originalSuggestion = event.detail;
+ applySelectedValue(originalSuggestion.value);
+
+ inputField.dispatchEvent(
+ new CustomEvent('autocomplete', {
+ detail: Object.assign(
+ {
+ type: 'click',
+ },
+ originalSuggestion,
+ ),
+ }),
+ );
+ });
+}
+
+export { listenAutocomplete };
diff --git a/assets/js/boorujs.js b/assets/js/boorujs.js
index 7f127f80..80ae4a65 100644
--- a/assets/js/boorujs.js
+++ b/assets/js/boorujs.js
@@ -9,47 +9,25 @@ 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 */
- },
-
- fetchcomplete() {
- /* 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();
@@ -70,18 +48,17 @@ const actions = {
});
},
- focus(data) {
- document.querySelector(data.value).focus();
- },
-
- preventdefault() {
- /* The existence of this entry is enough */
- },
-
addtag(data) {
addTag(document.querySelector(data.el.closest('[data-target]').dataset.target), data.el.dataset.tagName);
},
+ hideParent(data) {
+ const base = data.el.closest(data.value);
+ if (base) {
+ base.classList.add('hidden');
+ }
+ },
+
tab(data) {
const block = data.el.parentNode.parentNode,
newTab = $(`.block__tab[data-tab="${data.value}"]`),
@@ -114,12 +91,10 @@ const actions = {
});
}
},
-
- 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);
diff --git a/assets/js/galleries.ts b/assets/js/galleries.ts
index bf00c3c6..7a4142ce 100644
--- a/assets/js/galleries.ts
+++ b/assets/js/galleries.ts
@@ -22,9 +22,9 @@ export function setupGalleryEditing() {
initDraggables();
- $$('.media-box', containerEl).forEach(i => {
- i.draggable = true;
- });
+ for (const mediaBox of $$('.media-box', containerEl)) {
+ mediaBox.draggable = true;
+ }
rearrangeEl.addEventListener('click', () => {
sortableEl.classList.add('editing');
@@ -46,8 +46,8 @@ export function setupGalleryEditing() {
fetchJson('PATCH', reorderPath, {
image_ids: newImages,
- // copy the array again so that we have the newly updated set
}).then(() => {
+ // copy the array again so that we have the newly updated set
oldImages = newImages.slice();
});
});
diff --git a/assets/js/interactions.js b/assets/js/interactions.js
index fc9994a8..7b104a21 100644
--- a/assets/js/interactions.js
+++ b/assets/js/interactions.js
@@ -95,9 +95,7 @@ function showHidden(imageId) {
function resetVoted(imageId) {
uncacheStatus(imageId, 'voted');
-
onImage(imageId, '.interaction--upvote', el => el.classList.remove('active'));
-
onImage(imageId, '.interaction--downvote', el => el.classList.remove('active'));
}
diff --git a/assets/js/markdowntoolbar.js b/assets/js/markdowntoolbar.ts
similarity index 61%
rename from assets/js/markdowntoolbar.js
rename to assets/js/markdowntoolbar.ts
index 47a0e104..f9ceb840 100644
--- a/assets/js/markdowntoolbar.js
+++ b/assets/js/markdowntoolbar.ts
@@ -4,22 +4,40 @@
import { $, $$ } from './utils/dom';
-const markdownSyntax = {
+// List of options provided to the syntax handler function.
+interface SyntaxHandlerOptions {
+ prefix: string;
+ shortcutKeyCode: number;
+ suffix: string;
+ prefixMultiline: string;
+ suffixMultiline: string;
+ singleWrap: boolean;
+ escapeChar: string;
+ image: boolean;
+ text: string;
+}
+
+interface SyntaxHandler {
+ action: (textarea: HTMLTextAreaElement, options: Partial) => void;
+ options: Partial;
+}
+
+const markdownSyntax: Record = {
bold: {
action: wrapSelection,
- options: { prefix: '**', shortcutKey: 'b' },
+ options: { prefix: '**', shortcutKeyCode: 66 },
},
italics: {
action: wrapSelection,
- options: { prefix: '*', shortcutKey: 'i' },
+ options: { prefix: '*', shortcutKeyCode: 73 },
},
under: {
action: wrapSelection,
- options: { prefix: '__', shortcutKey: 'u' },
+ options: { prefix: '__', shortcutKeyCode: 85 },
},
spoiler: {
action: wrapSelection,
- options: { prefix: '||', shortcutKey: 's' },
+ options: { prefix: '||', shortcutKeyCode: 83 },
},
code: {
action: wrapSelectionOrLines,
@@ -29,7 +47,7 @@ const markdownSyntax = {
prefixMultiline: '```\n',
suffixMultiline: '\n```',
singleWrap: true,
- shortcutKey: 'e',
+ shortcutKeyCode: 69,
},
},
strike: {
@@ -50,11 +68,11 @@ const markdownSyntax = {
},
link: {
action: insertLink,
- options: { shortcutKey: 'l' },
+ options: { shortcutKeyCode: 76 },
},
image: {
action: insertLink,
- options: { image: true, shortcutKey: 'k' },
+ options: { image: true, shortcutKeyCode: 75 },
},
escape: {
action: escapeSelection,
@@ -62,14 +80,22 @@ const markdownSyntax = {
},
};
-function getSelections(textarea, linesOnly = false) {
+interface SelectionResult {
+ processLinesOnly: boolean;
+ selectedText: string;
+ beforeSelection: string;
+ afterSelection: string;
+}
+
+function getSelections(textarea: HTMLTextAreaElement, linesOnly: RegExp | boolean = false): SelectionResult {
let { selectionStart, selectionEnd } = textarea,
selection = textarea.value.substring(selectionStart, selectionEnd),
leadingSpace = '',
trailingSpace = '',
- caret;
+ caret: number;
const processLinesOnly = linesOnly instanceof RegExp ? linesOnly.test(selection) : linesOnly;
+
if (processLinesOnly) {
const explorer = /\n/g;
let startNewlineIndex = 0,
@@ -119,7 +145,18 @@ function getSelections(textarea, linesOnly = false) {
};
}
-function transformSelection(textarea, transformer, eachLine) {
+interface TransformResult {
+ newText: string;
+ caretOffset: number;
+}
+
+type TransformCallback = (selectedText: string, processLinesOnly: boolean) => TransformResult;
+
+function transformSelection(
+ textarea: HTMLTextAreaElement,
+ transformer: TransformCallback,
+ eachLine: RegExp | boolean = false,
+) {
const { selectedText, beforeSelection, afterSelection, processLinesOnly } = getSelections(textarea, eachLine),
// For long comments, record scrollbar position to restore it later
{ scrollTop } = textarea;
@@ -140,7 +177,7 @@ function transformSelection(textarea, transformer, eachLine) {
textarea.dispatchEvent(new Event('change'));
}
-function insertLink(textarea, options) {
+function insertLink(textarea: HTMLTextAreaElement, options: Partial) {
let hyperlink = window.prompt(options.image ? 'Image link:' : 'Link:');
if (!hyperlink || hyperlink === '') return;
@@ -155,10 +192,11 @@ function insertLink(textarea, options) {
wrapSelection(textarea, { prefix, suffix });
}
-function wrapSelection(textarea, options) {
- transformSelection(textarea, selectedText => {
+function wrapSelection(textarea: HTMLTextAreaElement, options: Partial) {
+ transformSelection(textarea, (selectedText: string): TransformResult => {
const { text = selectedText, prefix = '', suffix = options.prefix } = options,
emptyText = text === '';
+
let newText = text;
if (!emptyText) {
@@ -176,10 +214,14 @@ function wrapSelection(textarea, options) {
});
}
-function wrapLines(textarea, options, eachLine = true) {
+function wrapLines(
+ textarea: HTMLTextAreaElement,
+ options: Partial,
+ eachLine: RegExp | boolean = true,
+) {
transformSelection(
textarea,
- (selectedText, processLinesOnly) => {
+ (selectedText: string, processLinesOnly: boolean): TransformResult => {
const { text = selectedText, singleWrap = false } = options,
prefix = (processLinesOnly && options.prefixMultiline) || options.prefix || '',
suffix = (processLinesOnly && options.suffixMultiline) || options.suffix || '',
@@ -200,16 +242,22 @@ function wrapLines(textarea, options, eachLine = true) {
);
}
-function wrapSelectionOrLines(textarea, options) {
+function wrapSelectionOrLines(textarea: HTMLTextAreaElement, options: Partial) {
wrapLines(textarea, options, /\n/);
}
-function escapeSelection(textarea, options) {
- transformSelection(textarea, selectedText => {
+function escapeSelection(textarea: HTMLTextAreaElement, options: Partial) {
+ transformSelection(textarea, (selectedText: string): TransformResult => {
const { text = selectedText } = options,
emptyText = text === '';
- if (emptyText) return;
+ // Nothing to escape, so do nothing
+ if (emptyText) {
+ return {
+ newText: text,
+ caretOffset: text.length,
+ };
+ }
const newText = text.replace(/([*_[\]()^`%\\~<>#|])/g, '\\$1');
@@ -220,34 +268,55 @@ function escapeSelection(textarea, options) {
});
}
-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),
+function clickHandler(event: MouseEvent) {
+ if (!(event.target instanceof HTMLElement)) return;
+
+ const button = event.target.closest('.communication__toolbar__button');
+ const toolbar = button?.closest('.communication__toolbar');
+
+ if (!button || !toolbar?.parentElement) return;
+
+ // 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
+ const textarea = $('.js-toolbar-input', toolbar.parentElement),
id = button.dataset.syntaxId;
+ if (!textarea || !id) return;
+
markdownSyntax[id].action(textarea, markdownSyntax[id].options);
textarea.focus();
}
-function shortcutHandler(event) {
- if (
- !event.ctrlKey ||
- (window.navigator.platform === 'MacIntel' && !event.metaKey) ||
- event.shiftKey ||
- event.altKey
- ) {
+function canAcceptShortcut(event: KeyboardEvent): boolean {
+ let ctrl: boolean, otherModifier: boolean;
+
+ 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: KeyboardEvent) {
+ if (!canAcceptShortcut(event)) {
return;
}
+
const textarea = event.target,
- key = event.key.toLowerCase();
+ keyCode = event.keyCode;
+
+ if (!(textarea instanceof HTMLTextAreaElement)) return;
for (const id in markdownSyntax) {
- if (key === markdownSyntax[id].options.shortcutKey) {
+ if (keyCode === markdownSyntax[id].options.shortcutKeyCode) {
markdownSyntax[id].action(textarea, markdownSyntax[id].options);
event.preventDefault();
}
@@ -255,10 +324,10 @@ function shortcutHandler(event) {
}
function setupToolbar() {
- $$('.communication__toolbar').forEach(toolbar => {
+ $$('.communication__toolbar').forEach(toolbar => {
toolbar.addEventListener('click', clickHandler);
});
- $$('.js-toolbar-input').forEach(textarea => {
+ $$('.js-toolbar-input').forEach(textarea => {
textarea.addEventListener('keydown', shortcutHandler);
});
}
diff --git a/assets/js/notifications.ts b/assets/js/notifications.ts
index 7d007b26..d4446102 100644
--- a/assets/js/notifications.ts
+++ b/assets/js/notifications.ts
@@ -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', {
diff --git a/assets/js/pmwarning.ts b/assets/js/pmwarning.ts
index a8e0bf3f..9068d1b3 100644
--- a/assets/js/pmwarning.ts
+++ b/assets/js/pmwarning.ts
@@ -18,7 +18,7 @@ export function warnAboutPMs() {
if (value.match(imageEmbedRegex)) {
showEl(warning);
- } else if (!warning.classList.contains('hidden')) {
+ } else {
hideEl(warning);
}
});
diff --git a/assets/js/query/date.ts b/assets/js/query/date.ts
index a3404afd..ab7f9955 100644
--- a/assets/js/query/date.ts
+++ b/assets/js/query/date.ts
@@ -57,8 +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];
diff --git a/assets/js/query/lex.ts b/assets/js/query/lex.ts
index 80a2ce98..e98d8840 100644
--- a/assets/js/query/lex.ts
+++ b/assets/js/query/lex.ts
@@ -32,8 +32,8 @@ export interface LexResult {
}
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 = '';
@@ -85,11 +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();
}
diff --git a/assets/js/query/literal.ts b/assets/js/query/literal.ts
index 3694a20f..0c057d39 100644
--- a/assets/js/query/literal.ts
+++ b/assets/js/query/literal.ts
@@ -22,15 +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);
diff --git a/assets/js/quick-tag.js b/assets/js/quick-tag.js
index c75a37d5..4457784a 100644
--- a/assets/js/quick-tag.js
+++ b/assets/js/quick-tag.js
@@ -74,9 +74,9 @@ function submit() {
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);
diff --git a/assets/js/shortcuts.ts b/assets/js/shortcuts.ts
index ac0745d8..3de21c89 100644
--- a/assets/js/shortcuts.ts
+++ b/assets/js/shortcuts.ts
@@ -4,7 +4,7 @@
import { $ } from './utils/dom';
-type ShortcutKeyMap = Record void>;
+type ShortcutKeyMap = Record void>;
function getHover(): string | null {
const thumbBoxHover = $('.media-box:hover');
@@ -48,30 +48,32 @@ function isOK(event: KeyboardEvent): boolean {
}
/* 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() {
+ 74() { click('.js-prev'); }, // J - go to previous image
+ 73() { click('.js-up'); }, // I - go to index page
+ 75() { click('.js-next'); }, // K - go to next image
+ 82() { click('.js-rand'); }, // R - go to random image
+ 83() { click('.js-source-link'); }, // S - go to image source
+ 76() { click('.js-tag-sauce-toggle'); }, // L - edit tags
+ 79() { openFullView(); }, // O - open original
+ 86() { openFullViewNewTab(); }, // V - open original in a new tab
+ 70() {
// F - favourite image
click(getHover() ? `a.interaction--fave[data-image-id="${getHover()}"]` : '.block__header a.interaction--fave');
},
- u() {
+ 85() {
// 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]) {
- keyCodes[event.key]();
+ if (isOK(event) && keyCodes[event.keyCode]) {
+ keyCodes[event.keyCode]();
event.preventDefault();
}
});
diff --git a/assets/js/timeago.ts b/assets/js/timeago.ts
index 3eb8ec6a..a53f2f58 100644
--- a/assets/js/timeago.ts
+++ b/assets/js/timeago.ts
@@ -35,12 +35,12 @@ 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)) ||
diff --git a/assets/js/upload.js b/assets/js/upload.js
index 16d33959..0f931037 100644
--- a/assets/js/upload.js
+++ b/assets/js/upload.js
@@ -2,6 +2,7 @@
* Fetch and display preview images for various image upload forms.
*/
+import { assertNotNull } from './utils/assert';
import { fetchJson, handleError } from './utils/requests';
import { $, $$, clearEl, hideEl, makeEl, showEl } from './utils/dom';
import { addTag } from './tagsinput';
@@ -171,9 +172,98 @@ function setupImageUpload() {
window.removeEventListener('beforeunload', beforeUnload);
}
+ function createTagError(message) {
+ const buttonAfter = $('#tagsinput-save');
+ const errorElement = makeEl('span', { className: 'help-block tag-error', innerText: message });
+
+ buttonAfter.insertAdjacentElement('beforebegin', errorElement);
+ }
+
+ function clearTagErrors() {
+ $$('.tag-error').forEach(el => el.remove());
+ }
+
+ const ratingsTags = ['safe', 'suggestive', 'questionable', 'explicit', 'semi-grimdark', 'grimdark', 'grotesque'];
+
+ // populate tag error helper bars as necessary
+ // return true if all checks pass
+ // return false if any check fails
+ function validateTags() {
+ const tagInput = $('textarea.js-taginput');
+
+ if (!tagInput) {
+ return true;
+ }
+
+ const tagsArr = tagInput.value.split(',').map(t => t.trim());
+
+ const errors = [];
+
+ let hasRating = false;
+ let hasSafe = false;
+ let hasOtherRating = false;
+
+ tagsArr.forEach(tag => {
+ if (ratingsTags.includes(tag)) {
+ hasRating = true;
+ if (tag === 'safe') {
+ hasSafe = true;
+ } else {
+ hasOtherRating = true;
+ }
+ }
+ });
+
+ if (!hasRating) {
+ errors.push('Tag input must contain at least one rating tag');
+ } else if (hasSafe && hasOtherRating) {
+ errors.push('Tag input may not contain any other rating if safe');
+ }
+
+ if (tagsArr.length < 3) {
+ errors.push('Tag input must contain at least 3 tags');
+ }
+
+ errors.forEach(msg => createTagError(msg));
+
+ return errors.length === 0; // true: valid if no errors
+ }
+
+ function disableUploadButton() {
+ const submitButton = $('.button.input--separate-top');
+ if (submitButton !== null) {
+ submitButton.disabled = true;
+ submitButton.innerText = 'Please wait...';
+ }
+
+ // delay is needed because Safari stops the submit if the button is immediately disabled
+ requestAnimationFrame(() => submitButton.setAttribute('disabled', 'disabled'));
+ }
+
+ function submitHandler(event) {
+ // Remove any existing tag error elements
+ clearTagErrors();
+
+ if (validateTags()) {
+ // Disable navigation check
+ unregisterBeforeUnload();
+
+ // Prevent duplicate attempts to submit the form
+ disableUploadButton();
+
+ // Let the form submission complete
+ } else {
+ // Scroll to view validation errors
+ assertNotNull($('.fancy-tag-upload')).scrollIntoView();
+
+ // Prevent the form from being submitted
+ event.preventDefault();
+ }
+ }
+
fileField.addEventListener('change', registerBeforeUnload);
fetchButton.addEventListener('click', registerBeforeUnload);
- form.addEventListener('submit', unregisterBeforeUnload);
+ form.addEventListener('submit', submitHandler);
}
export { setupImageUpload };
diff --git a/assets/js/utils/__tests__/events.spec.ts b/assets/js/utils/__tests__/events.spec.ts
index 575883b7..ab1dbd67 100644
--- a/assets/js/utils/__tests__/events.spec.ts
+++ b/assets/js/utils/__tests__/events.spec.ts
@@ -1,4 +1,4 @@
-import { delegate, fire, leftClick, on, PhilomenaAvailableEventsMap } from '../events';
+import { delegate, fire, mouseMoveThenOver, leftClick, on, PhilomenaAvailableEventsMap } from '../events';
import { getRandomArrayItem } from '../../../test/randomness';
import { fireEvent } from '@testing-library/dom';
@@ -80,6 +80,55 @@ describe('Event utils', () => {
});
});
+ describe('mouseMoveThenOver', () => {
+ it('should NOT fire on first mouseover', () => {
+ const mockButton = document.createElement('button');
+ const mockHandler = vi.fn();
+
+ mouseMoveThenOver(mockButton, mockHandler);
+
+ fireEvent.mouseOver(mockButton);
+
+ expect(mockHandler).toHaveBeenCalledTimes(0);
+ });
+
+ it('should fire on the first mousemove', () => {
+ const mockButton = document.createElement('button');
+ const mockHandler = vi.fn();
+
+ mouseMoveThenOver(mockButton, mockHandler);
+
+ fireEvent.mouseMove(mockButton);
+
+ expect(mockHandler).toHaveBeenCalledTimes(1);
+ });
+
+ it('should fire on subsequent mouseover', () => {
+ const mockButton = document.createElement('button');
+ const mockHandler = vi.fn();
+
+ mouseMoveThenOver(mockButton, mockHandler);
+
+ fireEvent.mouseMove(mockButton);
+ fireEvent.mouseOver(mockButton);
+
+ expect(mockHandler).toHaveBeenCalledTimes(2);
+ });
+
+ it('should NOT fire on subsequent mousemove', () => {
+ const mockButton = document.createElement('button');
+ const mockHandler = vi.fn();
+
+ mouseMoveThenOver(mockButton, mockHandler);
+
+ fireEvent.mouseMove(mockButton);
+ fireEvent.mouseOver(mockButton);
+ fireEvent.mouseMove(mockButton);
+
+ expect(mockHandler).toHaveBeenCalledTimes(2);
+ });
+ });
+
describe('delegate', () => {
it('should call the native addEventListener method on the element', () => {
const mockElement = document.createElement('div');
diff --git a/assets/js/utils/__tests__/local-autocompleter.spec.ts b/assets/js/utils/__tests__/local-autocompleter.spec.ts
index 2310c92d..5bef0fe1 100644
--- a/assets/js/utils/__tests__/local-autocompleter.spec.ts
+++ b/assets/js/utils/__tests__/local-autocompleter.spec.ts
@@ -58,42 +58,44 @@ describe('Local Autocompleter', () => {
});
it('should return suggestions for exact tag name match', () => {
- const result = localAc.topK('safe', defaultK);
- expect(result).toEqual([expect.objectContaining({ name: 'safe', imageCount: 6 })]);
+ const result = localAc.matchPrefix('safe').topK(defaultK);
+ expect(result).toEqual([expect.objectContaining({ aliasName: 'safe', name: 'safe', imageCount: 6 })]);
});
it('should return suggestion for original tag when passed an alias', () => {
- const result = localAc.topK('flowers', defaultK);
- expect(result).toEqual([expect.objectContaining({ name: 'flower', imageCount: 1 })]);
+ const result = localAc.matchPrefix('flowers').topK(defaultK);
+ expect(result).toEqual([expect.objectContaining({ aliasName: 'flowers', name: 'flower', imageCount: 1 })]);
});
it('should return suggestions sorted by image count', () => {
- const result = localAc.topK(termStem, defaultK);
+ const result = localAc.matchPrefix(termStem).topK(defaultK);
expect(result).toEqual([
- expect.objectContaining({ name: 'forest', imageCount: 3 }),
- expect.objectContaining({ name: 'fog', imageCount: 1 }),
- expect.objectContaining({ name: 'force field', imageCount: 1 }),
+ expect.objectContaining({ aliasName: 'forest', name: 'forest', imageCount: 3 }),
+ expect.objectContaining({ aliasName: 'fog', name: 'fog', imageCount: 1 }),
+ expect.objectContaining({ aliasName: 'force field', name: 'force field', imageCount: 1 }),
]);
});
it('should return namespaced suggestions without including namespace', () => {
- const result = localAc.topK('test', defaultK);
- expect(result).toEqual([expect.objectContaining({ name: 'artist:test', imageCount: 1 })]);
+ const result = localAc.matchPrefix('test').topK(defaultK);
+ expect(result).toEqual([
+ expect.objectContaining({ aliasName: 'artist:test', name: 'artist:test', imageCount: 1 }),
+ ]);
});
it('should return only the required number of suggestions', () => {
- const result = localAc.topK(termStem, 1);
- expect(result).toEqual([expect.objectContaining({ name: 'forest', imageCount: 3 })]);
+ const result = localAc.matchPrefix(termStem).topK(1);
+ expect(result).toEqual([expect.objectContaining({ aliasName: 'forest', name: 'forest', imageCount: 3 })]);
});
it('should NOT return suggestions associated with hidden tags', () => {
window.booru.hiddenTagList = [1];
- const result = localAc.topK(termStem, defaultK);
+ const result = localAc.matchPrefix(termStem).topK(defaultK);
expect(result).toEqual([]);
});
it('should return empty array for empty prefix', () => {
- const result = localAc.topK('', defaultK);
+ const result = localAc.matchPrefix('').topK(defaultK);
expect(result).toEqual([]);
});
});
diff --git a/assets/js/utils/__tests__/suggestions.spec.ts b/assets/js/utils/__tests__/suggestions.spec.ts
new file mode 100644
index 00000000..59102d2b
--- /dev/null
+++ b/assets/js/utils/__tests__/suggestions.spec.ts
@@ -0,0 +1,334 @@
+import { fetchMock } from '../../../test/fetch-mock.ts';
+import {
+ fetchLocalAutocomplete,
+ fetchSuggestions,
+ purgeSuggestionsCache,
+ SuggestionsPopup,
+ TermSuggestion,
+} from '../suggestions.ts';
+import fs from 'fs';
+import path from 'path';
+import { LocalAutocompleter } from '../local-autocompleter.ts';
+import { afterEach } from 'vitest';
+import { fireEvent } from '@testing-library/dom';
+
+const mockedSuggestionsEndpoint = '/endpoint?term=';
+const mockedSuggestionsResponse = [
+ { label: 'artist:assasinmonkey (1)', value: 'artist:assasinmonkey' },
+ { label: 'artist:hydrusbeta (1)', value: 'artist:hydrusbeta' },
+ { label: 'artist:the sexy assistant (1)', value: 'artist:the sexy assistant' },
+ { label: 'artist:devinian (1)', value: 'artist:devinian' },
+ { label: 'artist:moe (1)', value: 'artist:moe' },
+];
+
+function mockBaseSuggestionsPopup(includeMockedSuggestions: boolean = false): [SuggestionsPopup, HTMLInputElement] {
+ const input = document.createElement('input');
+ const popup = new SuggestionsPopup();
+
+ document.body.append(input);
+ popup.showForField(input);
+
+ if (includeMockedSuggestions) {
+ popup.renderSuggestions(mockedSuggestionsResponse);
+ }
+
+ return [popup, input];
+}
+
+const selectedItemClassName = 'autocomplete__item--selected';
+
+describe('Suggestions', () => {
+ let mockedAutocompleteBuffer: ArrayBuffer;
+ let popup: SuggestionsPopup | undefined;
+ let input: HTMLInputElement | undefined;
+
+ beforeAll(async () => {
+ fetchMock.enableMocks();
+
+ mockedAutocompleteBuffer = await fs.promises
+ .readFile(path.join(__dirname, 'autocomplete-compiled-v2.bin'))
+ .then(fileBuffer => fileBuffer.buffer);
+ });
+
+ afterAll(() => {
+ fetchMock.disableMocks();
+ });
+
+ beforeEach(() => {
+ purgeSuggestionsCache();
+ fetchMock.resetMocks();
+ });
+
+ afterEach(() => {
+ if (input) {
+ input.remove();
+ input = undefined;
+ }
+
+ if (popup) {
+ popup.hide();
+ popup = undefined;
+ }
+ });
+
+ describe('SuggestionsPopup', () => {
+ it('should create the popup container', () => {
+ [popup, input] = mockBaseSuggestionsPopup();
+
+ expect(document.querySelector('.autocomplete')).toBeInstanceOf(HTMLElement);
+ expect(popup.isActive).toBe(true);
+ });
+
+ it('should be removed when hidden', () => {
+ [popup, input] = mockBaseSuggestionsPopup();
+
+ popup.hide();
+
+ expect(document.querySelector('.autocomplete')).not.toBeInstanceOf(HTMLElement);
+ expect(popup.isActive).toBe(false);
+ });
+
+ it('should render suggestions', () => {
+ [popup, input] = mockBaseSuggestionsPopup(true);
+
+ expect(document.querySelectorAll('.autocomplete__item').length).toBe(mockedSuggestionsResponse.length);
+ });
+
+ it('should initially select first element when selectNext called', () => {
+ [popup, input] = mockBaseSuggestionsPopup(true);
+
+ popup.selectNext();
+
+ expect(document.querySelector('.autocomplete__item:first-child')).toHaveClass(selectedItemClassName);
+ });
+
+ it('should initially select last element when selectPrevious called', () => {
+ [popup, input] = mockBaseSuggestionsPopup(true);
+
+ popup.selectPrevious();
+
+ expect(document.querySelector('.autocomplete__item:last-child')).toHaveClass(selectedItemClassName);
+ });
+
+ it('should select and de-select items when hovering items over', () => {
+ [popup, input] = mockBaseSuggestionsPopup(true);
+
+ const firstItem = document.querySelector('.autocomplete__item:first-child');
+ const lastItem = document.querySelector('.autocomplete__item:last-child');
+
+ if (firstItem) {
+ fireEvent.mouseOver(firstItem);
+ fireEvent.mouseMove(firstItem);
+ }
+
+ expect(firstItem).toHaveClass(selectedItemClassName);
+
+ if (lastItem) {
+ fireEvent.mouseOver(lastItem);
+ fireEvent.mouseMove(lastItem);
+ }
+
+ expect(firstItem).not.toHaveClass(selectedItemClassName);
+ expect(lastItem).toHaveClass(selectedItemClassName);
+
+ if (lastItem) {
+ fireEvent.mouseOut(lastItem);
+ }
+
+ expect(lastItem).not.toHaveClass(selectedItemClassName);
+ });
+
+ it('should allow switching between mouse and selection', () => {
+ [popup, input] = mockBaseSuggestionsPopup(true);
+
+ const secondItem = document.querySelector('.autocomplete__item:nth-child(2)');
+ const thirdItem = document.querySelector('.autocomplete__item:nth-child(3)');
+
+ if (secondItem) {
+ fireEvent.mouseOver(secondItem);
+ fireEvent.mouseMove(secondItem);
+ }
+
+ expect(secondItem).toHaveClass(selectedItemClassName);
+
+ popup.selectNext();
+
+ expect(secondItem).not.toHaveClass(selectedItemClassName);
+ expect(thirdItem).toHaveClass(selectedItemClassName);
+ });
+
+ it('should loop around when selecting next on last and previous on first', () => {
+ [popup, input] = mockBaseSuggestionsPopup(true);
+
+ const firstItem = document.querySelector('.autocomplete__item:first-child');
+ const lastItem = document.querySelector('.autocomplete__item:last-child');
+
+ if (lastItem) {
+ fireEvent.mouseOver(lastItem);
+ fireEvent.mouseMove(lastItem);
+ }
+
+ expect(lastItem).toHaveClass(selectedItemClassName);
+
+ popup.selectNext();
+
+ expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull();
+
+ popup.selectNext();
+
+ expect(firstItem).toHaveClass(selectedItemClassName);
+
+ popup.selectPrevious();
+
+ expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull();
+
+ popup.selectPrevious();
+
+ expect(lastItem).toHaveClass(selectedItemClassName);
+ });
+
+ it('should return selected item value', () => {
+ [popup, input] = mockBaseSuggestionsPopup(true);
+
+ expect(popup.selectedTerm).toBe(null);
+
+ popup.selectNext();
+
+ expect(popup.selectedTerm).toBe(mockedSuggestionsResponse[0].value);
+ });
+
+ it('should emit an event when item was clicked with mouse', () => {
+ [popup, input] = mockBaseSuggestionsPopup(true);
+
+ let clickEvent: CustomEvent | undefined;
+
+ const itemSelectedHandler = vi.fn((event: CustomEvent) => {
+ clickEvent = event;
+ });
+
+ popup.onItemSelected(itemSelectedHandler);
+
+ const firstItem = document.querySelector('.autocomplete__item');
+
+ if (firstItem) {
+ fireEvent.click(firstItem);
+ }
+
+ expect(itemSelectedHandler).toBeCalledTimes(1);
+ expect(clickEvent?.detail).toEqual(mockedSuggestionsResponse[0]);
+ });
+
+ it('should not emit selection on items without value', () => {
+ [popup, input] = mockBaseSuggestionsPopup();
+
+ popup.renderSuggestions([{ label: 'Option without value', value: '' }]);
+
+ const itemSelectionHandler = vi.fn();
+
+ popup.onItemSelected(itemSelectionHandler);
+
+ const firstItem = document.querySelector('.autocomplete__item:first-child')!;
+
+ if (firstItem) {
+ fireEvent.click(firstItem);
+ }
+
+ expect(itemSelectionHandler).not.toBeCalled();
+ });
+ });
+
+ describe('fetchSuggestions', () => {
+ it('should only call fetch once per single term', () => {
+ fetchSuggestions(mockedSuggestionsEndpoint, 'art');
+ fetchSuggestions(mockedSuggestionsEndpoint, 'art');
+
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('should be case-insensitive to terms and trim spaces', () => {
+ fetchSuggestions(mockedSuggestionsEndpoint, 'art');
+ fetchSuggestions(mockedSuggestionsEndpoint, 'Art');
+ fetchSuggestions(mockedSuggestionsEndpoint, ' ART ');
+
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return the same suggestions from cache', async () => {
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 }));
+
+ const firstSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
+ const secondSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
+
+ expect(firstSuggestions).toBe(secondSuggestions);
+ });
+
+ it('should parse and return array of suggestions', async () => {
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 }));
+
+ const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
+
+ expect(resolvedSuggestions).toBeInstanceOf(Array);
+ expect(resolvedSuggestions.length).toBe(mockedSuggestionsResponse.length);
+ expect(resolvedSuggestions).toEqual(mockedSuggestionsResponse);
+ });
+
+ it('should return empty array on server error', async () => {
+ fetchMock.mockResolvedValueOnce(new Response('', { status: 500 }));
+
+ const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'unknown tag');
+
+ expect(resolvedSuggestions).toBeInstanceOf(Array);
+ expect(resolvedSuggestions.length).toBe(0);
+ });
+
+ it('should return empty array on invalid response format', async () => {
+ fetchMock.mockResolvedValueOnce(new Response('invalid non-JSON response', { status: 200 }));
+
+ const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'invalid response');
+
+ expect(resolvedSuggestions).toBeInstanceOf(Array);
+ expect(resolvedSuggestions.length).toBe(0);
+ });
+ });
+
+ describe('purgeSuggestionsCache', () => {
+ it('should clear cached responses', async () => {
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 }));
+
+ const firstResult = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
+ purgeSuggestionsCache();
+ const resultAfterPurge = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
+
+ expect(fetch).toBeCalledTimes(2);
+ expect(firstResult).not.toBe(resultAfterPurge);
+ });
+ });
+
+ describe('fetchLocalAutocomplete', () => {
+ it('should request binary with date-related cache key', () => {
+ fetchMock.mockResolvedValue(new Response(mockedAutocompleteBuffer, { status: 200 }));
+
+ const now = new Date();
+ const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`;
+ const expectedEndpoint = `/autocomplete/compiled?vsn=2&key=${cacheKey}`;
+
+ fetchLocalAutocomplete();
+
+ expect(fetch).toBeCalledWith(expectedEndpoint, { credentials: 'omit', cache: 'force-cache' });
+ });
+
+ it('should return auto-completer instance', async () => {
+ fetchMock.mockResolvedValue(new Response(mockedAutocompleteBuffer, { status: 200 }));
+
+ const autocomplete = await fetchLocalAutocomplete();
+
+ expect(autocomplete).toBeInstanceOf(LocalAutocompleter);
+ });
+
+ it('should throw generic server error on failing response', async () => {
+ fetchMock.mockResolvedValue(new Response('error', { status: 500 }));
+
+ expect(() => fetchLocalAutocomplete()).rejects.toThrowError('Received error from server');
+ });
+ });
+});
diff --git a/assets/js/utils/__tests__/unique-heap.spec.ts b/assets/js/utils/__tests__/unique-heap.spec.ts
new file mode 100644
index 00000000..e7127ef6
--- /dev/null
+++ b/assets/js/utils/__tests__/unique-heap.spec.ts
@@ -0,0 +1,70 @@
+import { UniqueHeap } from '../unique-heap';
+
+describe('Unique Heap', () => {
+ interface Result {
+ name: string;
+ }
+
+ function compare(a: Result, b: Result): boolean {
+ return a.name < b.name;
+ }
+
+ test('it should return no results when empty', () => {
+ const heap = new UniqueHeap(compare, 'name');
+ expect(heap.topK(5)).toEqual([]);
+ });
+
+ test("doesn't insert duplicate results", () => {
+ const heap = new UniqueHeap(compare, 'name');
+
+ heap.append({ name: 'name' });
+ heap.append({ name: 'name' });
+
+ expect(heap.topK(2)).toEqual([expect.objectContaining({ name: 'name' })]);
+ });
+
+ test('it should return results in reverse sorted order', () => {
+ const heap = new UniqueHeap(compare, 'name');
+
+ const names = [
+ 'alpha',
+ 'beta',
+ 'gamma',
+ 'delta',
+ 'epsilon',
+ 'zeta',
+ 'eta',
+ 'theta',
+ 'iota',
+ 'kappa',
+ 'lambda',
+ 'mu',
+ 'nu',
+ 'xi',
+ 'omicron',
+ 'pi',
+ 'rho',
+ 'sigma',
+ 'tau',
+ 'upsilon',
+ 'phi',
+ 'chi',
+ 'psi',
+ 'omega',
+ ];
+
+ for (const name of names) {
+ heap.append({ name });
+ }
+
+ const results = heap.topK(5);
+
+ expect(results).toEqual([
+ expect.objectContaining({ name: 'zeta' }),
+ expect.objectContaining({ name: 'xi' }),
+ expect.objectContaining({ name: 'upsilon' }),
+ expect.objectContaining({ name: 'theta' }),
+ expect.objectContaining({ name: 'tau' }),
+ ]);
+ });
+});
diff --git a/assets/js/utils/events.ts b/assets/js/utils/events.ts
index 70460bf8..458df039 100644
--- a/assets/js/utils/events.ts
+++ b/assets/js/utils/events.ts
@@ -43,6 +43,17 @@ export function leftClick(func
};
}
+export function mouseMoveThenOver(element: El, func: (e: MouseEvent) => void) {
+ element.addEventListener(
+ 'mousemove',
+ (event: MouseEvent) => {
+ func(event);
+ element.addEventListener('mouseover', func);
+ },
+ { once: true },
+ );
+}
+
export function delegate(
node: PhilomenaEventElement,
event: K,
diff --git a/assets/js/utils/local-autocompleter.ts b/assets/js/utils/local-autocompleter.ts
index ec3ba162..8b752136 100644
--- a/assets/js/utils/local-autocompleter.ts
+++ b/assets/js/utils/local-autocompleter.ts
@@ -1,12 +1,21 @@
// Client-side tag completion.
+import { UniqueHeap } from './unique-heap';
import store from './store';
-interface Result {
+export interface Result {
+ aliasName: string;
name: string;
imageCount: number;
associations: number[];
}
+/**
+ * Returns whether Result a is considered less than Result b.
+ */
+function compareResult(a: Result, b: Result): boolean {
+ return a.imageCount === b.imageCount ? a.name > b.name : a.imageCount < b.imageCount;
+}
+
/**
* Compare two strings, C-style.
*/
@@ -18,10 +27,13 @@ function strcmp(a: string, b: string): number {
* Returns the name of a tag without any namespace component.
*/
function nameInNamespace(s: string): string {
- const v = s.split(':', 2);
+ const first = s.indexOf(':');
- if (v.length === 2) return v[1];
- return v[0];
+ if (first !== -1) {
+ return s.slice(first + 1);
+ }
+
+ return s;
}
/**
@@ -59,7 +71,7 @@ export class LocalAutocompleter {
/**
* Get a tag's name and its associations given a byte location inside the file.
*/
- getTagFromLocation(location: number): [string, number[]] {
+ private getTagFromLocation(location: number, imageCount: number, aliasName?: string): Result {
const nameLength = this.view.getUint8(location);
const assnLength = this.view.getUint8(location + 1 + nameLength);
@@ -70,29 +82,29 @@ export class LocalAutocompleter {
associations.push(this.view.getUint32(location + 1 + nameLength + 1 + i * 4, true));
}
- return [name, associations];
+ return { aliasName: aliasName || name, name, imageCount, associations };
}
/**
* Get a Result object as the ith tag inside the file.
*/
- getResultAt(i: number): [string, Result] {
- const nameLocation = this.view.getUint32(this.referenceStart + i * 8, true);
+ private getResultAt(i: number, aliasName?: string): Result {
+ const tagLocation = 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 result = this.getTagFromLocation(tagLocation, imageCount, aliasName);
if (imageCount < 0) {
// This is actually an alias, so follow it
- return [name, this.getResultAt(-imageCount - 1)[1]];
+ return this.getResultAt(-imageCount - 1, aliasName || result.name);
}
- return [name, { name, imageCount, associations }];
+ return result;
}
/**
* Get a Result object as the ith tag inside the file, secondary ordering.
*/
- getSecondaryResultAt(i: number): [string, Result] {
+ private getSecondaryResultAt(i: number): Result {
const referenceIndex = this.view.getUint32(this.secondaryStart + i * 4, true);
return this.getResultAt(referenceIndex);
}
@@ -100,23 +112,22 @@ export class LocalAutocompleter {
/**
* Perform a binary search to fetch all results matching a condition.
*/
- scanResults(
- getResult: (i: number) => [string, Result],
+ private scanResults(
+ getResult: (i: number) => Result,
compare: (name: string) => number,
- results: Record,
+ results: UniqueHeap,
+ hiddenTags: Set,
) {
- const unfilter = store.get('unfilter_tag_suggestions');
+ const filter = !store.get('unfilter_tag_suggestions');
let min = 0;
let max = this.numTags;
- const hiddenTags = window.booru.hiddenTagList;
-
while (min < max - 1) {
- const med = (min + (max - min) / 2) | 0;
- const sortKey = getResult(med)[0];
+ const med = min + (((max - min) / 2) | 0);
+ const result = getResult(med);
- if (compare(sortKey) >= 0) {
+ if (compare(result.aliasName) >= 0) {
// too large, go left
max = med;
} else {
@@ -126,40 +137,47 @@ export class LocalAutocompleter {
}
// Scan forward until no more matches occur
- while (min < this.numTags - 1) {
- const [sortKey, result] = getResult(++min);
- if (compare(sortKey) !== 0) {
+ outer: while (min < this.numTags - 1) {
+ const result = getResult(++min);
+
+ if (compare(result.aliasName) !== 0) {
break;
}
- // Add if not filtering or no associations are filtered
- if (unfilter || hiddenTags.findIndex(ht => result.associations.includes(ht)) === -1) {
- results[result.name] = result;
+ // Check if any associations are filtered
+ if (filter) {
+ for (const association of result.associations) {
+ if (hiddenTags.has(association)) {
+ continue outer;
+ }
+ }
}
+
+ // Nothing was filtered, so add
+ results.append(result);
}
}
/**
* Find the top k results by image count which match the given string prefix.
*/
- topK(prefix: string, k: number): Result[] {
- const results: Record = {};
+ matchPrefix(prefix: string): UniqueHeap {
+ const results = new UniqueHeap(compareResult, 'name');
if (prefix === '') {
- return [];
+ return results;
}
+ const hiddenTags = new Set(window.booru.hiddenTagList);
+
// Find normally, in full name-sorted order
const prefixMatch = (name: string) => strcmp(name.slice(0, prefix.length), prefix);
- this.scanResults(this.getResultAt.bind(this), prefixMatch, results);
+ this.scanResults(this.getResultAt.bind(this), prefixMatch, results, hiddenTags);
// Find in secondary order
const namespaceMatch = (name: string) => strcmp(nameInNamespace(name).slice(0, prefix.length), prefix);
- this.scanResults(this.getSecondaryResultAt.bind(this), namespaceMatch, results);
+ this.scanResults(this.getSecondaryResultAt.bind(this), namespaceMatch, results, hiddenTags);
- // Sort results by image count
- const sorted = Object.values(results).sort((a, b) => b.imageCount - a.imageCount);
-
- return sorted.slice(0, k);
+ return results;
}
}
diff --git a/assets/js/utils/suggestions.ts b/assets/js/utils/suggestions.ts
new file mode 100644
index 00000000..fb810be3
--- /dev/null
+++ b/assets/js/utils/suggestions.ts
@@ -0,0 +1,177 @@
+import { makeEl } from './dom.ts';
+import { mouseMoveThenOver } from './events.ts';
+import { handleError } from './requests.ts';
+import { LocalAutocompleter } from './local-autocompleter.ts';
+
+export interface TermSuggestion {
+ label: string;
+ value: string;
+}
+
+const selectedSuggestionClassName = 'autocomplete__item--selected';
+
+export class SuggestionsPopup {
+ private readonly container: HTMLElement;
+ private readonly listElement: HTMLUListElement;
+ private selectedElement: HTMLElement | null = null;
+
+ constructor() {
+ this.container = makeEl('div', {
+ className: 'autocomplete',
+ });
+
+ this.listElement = makeEl('ul', {
+ className: 'autocomplete__list',
+ });
+
+ this.container.appendChild(this.listElement);
+ }
+
+ get selectedTerm(): string | null {
+ return this.selectedElement?.dataset.value || null;
+ }
+
+ get isActive(): boolean {
+ return this.container.isConnected;
+ }
+
+ hide() {
+ this.clearSelection();
+ this.container.remove();
+ }
+
+ private clearSelection() {
+ if (!this.selectedElement) return;
+
+ this.selectedElement.classList.remove(selectedSuggestionClassName);
+ this.selectedElement = null;
+ }
+
+ private updateSelection(targetItem: HTMLElement) {
+ this.clearSelection();
+
+ this.selectedElement = targetItem;
+ this.selectedElement.classList.add(selectedSuggestionClassName);
+ }
+
+ renderSuggestions(suggestions: TermSuggestion[]): SuggestionsPopup {
+ this.clearSelection();
+
+ this.listElement.innerHTML = '';
+
+ for (const suggestedTerm of suggestions) {
+ const listItem = makeEl('li', {
+ className: 'autocomplete__item',
+ innerText: suggestedTerm.label,
+ });
+
+ listItem.dataset.value = suggestedTerm.value;
+
+ this.watchItem(listItem, suggestedTerm);
+ this.listElement.appendChild(listItem);
+ }
+
+ return this;
+ }
+
+ private watchItem(listItem: HTMLElement, suggestion: TermSuggestion) {
+ mouseMoveThenOver(listItem, () => this.updateSelection(listItem));
+
+ listItem.addEventListener('mouseout', () => this.clearSelection());
+
+ listItem.addEventListener('click', () => {
+ if (!listItem.dataset.value) {
+ return;
+ }
+
+ this.container.dispatchEvent(new CustomEvent('item_selected', { detail: suggestion }));
+ });
+ }
+
+ private changeSelection(direction: number) {
+ let nextTargetElement: Element | null;
+
+ if (!this.selectedElement) {
+ nextTargetElement = direction > 0 ? this.listElement.firstElementChild : this.listElement.lastElementChild;
+ } else {
+ nextTargetElement =
+ direction > 0 ? this.selectedElement.nextElementSibling : this.selectedElement.previousElementSibling;
+ }
+
+ if (!(nextTargetElement instanceof HTMLElement)) {
+ this.clearSelection();
+ return;
+ }
+
+ this.updateSelection(nextTargetElement);
+ }
+
+ selectNext() {
+ this.changeSelection(1);
+ }
+
+ selectPrevious() {
+ this.changeSelection(-1);
+ }
+
+ showForField(targetElement: HTMLElement) {
+ this.container.style.position = 'absolute';
+ this.container.style.left = `${targetElement.offsetLeft}px`;
+
+ let topPosition = targetElement.offsetTop + targetElement.offsetHeight;
+
+ if (targetElement.parentElement) {
+ topPosition -= targetElement.parentElement.scrollTop;
+ }
+
+ this.container.style.top = `${topPosition}px`;
+
+ document.body.appendChild(this.container);
+ }
+
+ onItemSelected(callback: (event: CustomEvent) => void) {
+ this.container.addEventListener('item_selected', callback as EventListener);
+ }
+}
+
+const cachedSuggestions = new Map>();
+
+export async function fetchSuggestions(endpoint: string, targetTerm: string): Promise {
+ const normalizedTerm = targetTerm.trim().toLowerCase();
+
+ if (cachedSuggestions.has(normalizedTerm)) {
+ return cachedSuggestions.get(normalizedTerm)!;
+ }
+
+ const promisedSuggestions: Promise = fetch(`${endpoint}${targetTerm}`)
+ .then(handleError)
+ .then(response => response.json())
+ .catch(() => {
+ // Deleting the promised result from cache to allow retrying
+ cachedSuggestions.delete(normalizedTerm);
+
+ // And resolve failed promise with empty array
+ return [];
+ });
+
+ cachedSuggestions.set(normalizedTerm, promisedSuggestions);
+
+ return promisedSuggestions;
+}
+
+export function purgeSuggestionsCache() {
+ cachedSuggestions.clear();
+}
+
+export async function fetchLocalAutocomplete(): Promise {
+ const now = new Date();
+ const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`;
+
+ return await fetch(`/autocomplete/compiled?vsn=2&key=${cacheKey}`, {
+ credentials: 'omit',
+ cache: 'force-cache',
+ })
+ .then(handleError)
+ .then(resp => resp.arrayBuffer())
+ .then(buf => new LocalAutocompleter(buf));
+}
diff --git a/assets/js/utils/tag.ts b/assets/js/utils/tag.ts
index fcd18d18..af2b1095 100644
--- a/assets/js/utils/tag.ts
+++ b/assets/js/utils/tag.ts
@@ -57,10 +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(', ');
diff --git a/assets/js/utils/unique-heap.ts b/assets/js/utils/unique-heap.ts
new file mode 100644
index 00000000..3b4e840c
--- /dev/null
+++ b/assets/js/utils/unique-heap.ts
@@ -0,0 +1,96 @@
+export type Compare = (a: T, b: T) => boolean;
+
+export class UniqueHeap {
+ private keys: Set;
+ private values: T[];
+ private keyName: keyof T;
+ private compare: Compare;
+
+ constructor(compare: Compare, keyName: keyof T) {
+ this.keys = new Set();
+ this.values = [];
+ this.keyName = keyName;
+ this.compare = compare;
+ }
+
+ append(value: T) {
+ const key = value[this.keyName];
+
+ if (!this.keys.has(key)) {
+ this.keys.add(key);
+ this.values.push(value);
+ }
+ }
+
+ topK(k: number): T[] {
+ // Create the output array.
+ const output: T[] = [];
+
+ for (const result of this.results()) {
+ if (output.length >= k) {
+ break;
+ }
+
+ output.push(result);
+ }
+
+ return output;
+ }
+
+ *results(): Generator {
+ const { values } = this;
+ const length = values.length;
+
+ // Build the heap.
+ for (let i = (length >> 1) - 1; i >= 0; i--) {
+ this.heapify(length, i);
+ }
+
+ // Begin extracting values.
+ for (let i = 0; i < length; i++) {
+ // Top value is the largest.
+ yield values[0];
+
+ // Swap with the element at the end.
+ const lastIndex = length - i - 1;
+ values[0] = values[lastIndex];
+
+ // Restore top value being the largest.
+ this.heapify(lastIndex, 0);
+ }
+ }
+
+ private heapify(length: number, initialIndex: number) {
+ const { compare, values } = this;
+ let i = initialIndex;
+
+ while (true) {
+ const left = 2 * i + 1;
+ const right = 2 * i + 2;
+ let largest = i;
+
+ if (left < length && compare(values[largest], values[left])) {
+ // Left child is in-bounds and larger than parent. Swap with left.
+ largest = left;
+ }
+
+ if (right < length && compare(values[largest], values[right])) {
+ // Right child is in-bounds and larger than parent or left. Swap with right.
+ largest = right;
+ }
+
+ if (largest === i) {
+ // Largest value was already the parent. Done.
+ return;
+ }
+
+ // Swap.
+ const temp = values[i];
+ values[i] = values[largest];
+ values[largest] = temp;
+
+ // Repair the subtree previously containing the largest element.
+ i = largest;
+ }
+ }
+}
diff --git a/assets/package-lock.json b/assets/package-lock.json
index cd0063c5..0f73ac68 100644
--- a/assets/package-lock.json
+++ b/assets/package-lock.json
@@ -15,26 +15,27 @@
"postcss-mixins": "^10.0.1",
"postcss-simple-vars": "^7.0.1",
"typescript": "^5.4",
- "vite": "^5.2"
+ "vite": "^5.4"
},
"devDependencies": {
"@testing-library/dom": "^10.1.0",
"@testing-library/jest-dom": "^6.4.6",
"@types/chai-dom": "^1.11.3",
- "@vitest/coverage-v8": "^1.6.0",
+ "@vitest/coverage-v8": "^2.1.0",
"chai": "^5",
- "eslint": "^9.4.0",
- "eslint-plugin-prettier": "^5.1.3",
+ "eslint": "^9.11.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-vitest": "^0.5.4",
"jest-environment-jsdom": "^29.7.0",
"jsdom": "^24.1.0",
- "prettier": "^3.3.2",
- "stylelint": "^16.6.1",
+ "prettier": "^3.3.3",
+ "stylelint": "^16.9.0",
"stylelint-config-standard": "^36.0.0",
"stylelint-prettier": "^5.0.0",
- "typescript-eslint": "8.0.0-alpha.39",
- "vitest": "^1.6.0",
- "vitest-fetch-mock": "^0.2.2"
+ "typescript-eslint": "8.8.0",
+ "vitest": "^2.1.0",
+ "vitest-fetch-mock": "^0.3.0"
}
},
"node_modules/@adobe/css-tools": {
@@ -322,29 +323,6 @@
"node": "^14 || ^16 || >=18"
}
},
- "node_modules/@csstools/media-query-list-parser": {
- "version": "2.1.12",
- "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.12.tgz",
- "integrity": "sha512-t1/CdyVJzOQUiGUcIBXRzTAkWTFPxiPnoKwowKW2z9Uj78c2bBWI/X94BeVfUwVq1xtCjD7dnO8kS6WONgp8Jw==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/csstools"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/csstools"
- }
- ],
- "engines": {
- "node": "^14 || ^16 || >=18"
- },
- "peerDependencies": {
- "@csstools/css-parser-algorithms": "^2.7.0",
- "@csstools/css-tokenizer": "^2.3.2"
- }
- },
"node_modules/@csstools/postcss-progressive-custom-properties": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-3.2.0.tgz",
@@ -398,9 +376,9 @@
}
},
"node_modules/@csstools/selector-specificity": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-3.1.1.tgz",
- "integrity": "sha512-a7cxGcJ2wIlMFLlh8z2ONm+715QkPHiyJcxwQlKOz/03GPw1COpfhcmC9wm4xlZfp//jWHNNMwzjtqHXVWU9KA==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-4.0.0.tgz",
+ "integrity": "sha512-189nelqtPd8++phaHNwYovKZI0FOzH1vQEE3QhHHkNIGrg5fSs9CbYP3RvfEH5geztnIA9Jwq91wyOIwAW5JIQ==",
"dev": true,
"funding": [
{
@@ -413,10 +391,10 @@
}
],
"engines": {
- "node": "^14 || ^16 || >=18"
+ "node": ">=18"
},
"peerDependencies": {
- "postcss-selector-parser": "^6.0.13"
+ "postcss-selector-parser": "^6.1.0"
}
},
"node_modules/@csstools/utilities": {
@@ -832,9 +810,9 @@
}
},
"node_modules/@eslint/config-array": {
- "version": "0.17.0",
- "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.17.0.tgz",
- "integrity": "sha512-A68TBu6/1mHHuc5YJL0U0VVeGNiklLAL6rRmhTCP2B5XjWLMnrX+HkO+IAXyHvks5cyyY1jjK5ITPQ1HGS2EVA==",
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz",
+ "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==",
"dev": true,
"dependencies": {
"@eslint/object-schema": "^2.1.4",
@@ -845,6 +823,15 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@eslint/core": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.6.0.tgz",
+ "integrity": "sha512-8I2Q8ykA4J0x0o7cg67FPVnehcqWTBehu/lmY+bolPFHGjh49YzGBMXTvpqVgEbBdvNCSxj6iFgiIyHzf03lzg==",
+ "dev": true,
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
"node_modules/@eslint/eslintrc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz",
@@ -869,9 +856,9 @@
}
},
"node_modules/@eslint/js": {
- "version": "9.6.0",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.6.0.tgz",
- "integrity": "sha512-D9B0/3vNg44ZeWbYMpBoXqNP4j6eQD5vNwIlGAuFRRzK/WtT/jvDQW3Bi9kkf3PMDMlM7Yi+73VLUsn5bJcl8A==",
+ "version": "9.11.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.11.1.tgz",
+ "integrity": "sha512-/qu+TWz8WwPWc7/HcIJKi+c+MOm46GdVaSlTTQcaqaL53+GsoA6MxWp5PtTx48qbSP7ylM1Kn7nhvkugfJvRSA==",
"dev": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -886,6 +873,18 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz",
+ "integrity": "sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==",
+ "dev": true,
+ "dependencies": {
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
"node_modules/@fortawesome/fontawesome-free": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.2.tgz",
@@ -921,6 +920,73 @@
"url": "https://github.com/sponsors/nzakas"
}
},
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
+ "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true
+ },
+ "node_modules/@isaacs/cliui/node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+ "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
"node_modules/@istanbuljs/schema": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
@@ -1024,9 +1090,9 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.4.15",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
- "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
+ "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"dev": true
},
"node_modules/@jridgewell/trace-mapping": {
@@ -1071,6 +1137,16 @@
"node": ">= 8"
}
},
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/@pkgr/core": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz",
@@ -1084,9 +1160,9 @@
}
},
"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",
- "integrity": "sha512-Tya6xypR10giZV1XzxmH5wr25VcZSncG0pZIjfePT0OVBvqNEurzValetGNarVrGiq66EBVAFn15iYX4w6FKgQ==",
+ "version": "4.22.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.5.tgz",
+ "integrity": "sha512-SU5cvamg0Eyu/F+kLeMXS7GoahL+OoizlclVFX3l5Ql6yNlywJJ0OuqTzUx0v+aHhPHEB/56CT06GQrRrGNYww==",
"cpu": [
"arm"
],
@@ -1096,9 +1172,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
- "version": "4.18.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.18.0.tgz",
- "integrity": "sha512-avCea0RAP03lTsDhEyfy+hpfr85KfyTctMADqHVhLAF3MlIkq83CP8UfAHUssgXTYd+6er6PaAhx/QGv4L1EiA==",
+ "version": "4.22.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.5.tgz",
+ "integrity": "sha512-S4pit5BP6E5R5C8S6tgU/drvgjtYW76FBuG6+ibG3tMvlD1h9LHVF9KmlmaUBQ8Obou7hEyS+0w+IR/VtxwNMQ==",
"cpu": [
"arm64"
],
@@ -1108,9 +1184,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.18.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.18.0.tgz",
- "integrity": "sha512-IWfdwU7KDSm07Ty0PuA/W2JYoZ4iTj3TUQjkVsO/6U+4I1jN5lcR71ZEvRh52sDOERdnNhhHU57UITXz5jC1/w==",
+ "version": "4.22.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.5.tgz",
+ "integrity": "sha512-250ZGg4ipTL0TGvLlfACkIxS9+KLtIbn7BCZjsZj88zSg2Lvu3Xdw6dhAhfe/FjjXPVNCtcSp+WZjVsD3a/Zlw==",
"cpu": [
"arm64"
],
@@ -1120,9 +1196,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.18.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.18.0.tgz",
- "integrity": "sha512-n2LMsUz7Ynu7DoQrSQkBf8iNrjOGyPLrdSg802vk6XT3FtsgX6JbE8IHRvposskFm9SNxzkLYGSq9QdpLYpRNA==",
+ "version": "4.22.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.5.tgz",
+ "integrity": "sha512-D8brJEFg5D+QxFcW6jYANu+Rr9SlKtTenmsX5hOSzNYVrK5oLAEMTUgKWYJP+wdKyCdeSwnapLsn+OVRFycuQg==",
"cpu": [
"x64"
],
@@ -1132,9 +1208,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.18.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.18.0.tgz",
- "integrity": "sha512-C/zbRYRXFjWvz9Z4haRxcTdnkPt1BtCkz+7RtBSuNmKzMzp3ZxdM28Mpccn6pt28/UWUCTXa+b0Mx1k3g6NOMA==",
+ "version": "4.22.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.5.tgz",
+ "integrity": "sha512-PNqXYmdNFyWNg0ma5LdY8wP+eQfdvyaBAojAXgO7/gs0Q/6TQJVXAXe8gwW9URjbS0YAammur0fynYGiWsKlXw==",
"cpu": [
"arm"
],
@@ -1144,9 +1220,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.18.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.18.0.tgz",
- "integrity": "sha512-l3m9ewPgjQSXrUMHg93vt0hYCGnrMOcUpTz6FLtbwljo2HluS4zTXFy2571YQbisTnfTKPZ01u/ukJdQTLGh9A==",
+ "version": "4.22.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.5.tgz",
+ "integrity": "sha512-kSSCZOKz3HqlrEuwKd9TYv7vxPYD77vHSUvM2y0YaTGnFc8AdI5TTQRrM1yIp3tXCKrSL9A7JLoILjtad5t8pQ==",
"cpu": [
"arm"
],
@@ -1156,9 +1232,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.18.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.18.0.tgz",
- "integrity": "sha512-rJ5D47d8WD7J+7STKdCUAgmQk49xuFrRi9pZkWoRD1UeSMakbcepWXPF8ycChBoAqs1pb2wzvbY6Q33WmN2ftw==",
+ "version": "4.22.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.5.tgz",
+ "integrity": "sha512-oTXQeJHRbOnwRnRffb6bmqmUugz0glXaPyspp4gbQOPVApdpRrY/j7KP3lr7M8kTfQTyrBUzFjj5EuHAhqH4/w==",
"cpu": [
"arm64"
],
@@ -1168,9 +1244,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.18.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.18.0.tgz",
- "integrity": "sha512-be6Yx37b24ZwxQ+wOQXXLZqpq4jTckJhtGlWGZs68TgdKXJgw54lUUoFYrg6Zs/kjzAQwEwYbp8JxZVzZLRepQ==",
+ "version": "4.22.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.5.tgz",
+ "integrity": "sha512-qnOTIIs6tIGFKCHdhYitgC2XQ2X25InIbZFor5wh+mALH84qnFHvc+vmWUpyX97B0hNvwNUL4B+MB8vJvH65Fw==",
"cpu": [
"arm64"
],
@@ -1180,9 +1256,9 @@
]
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
- "version": "4.18.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.18.0.tgz",
- "integrity": "sha512-hNVMQK+qrA9Todu9+wqrXOHxFiD5YmdEi3paj6vP02Kx1hjd2LLYR2eaN7DsEshg09+9uzWi2W18MJDlG0cxJA==",
+ "version": "4.22.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.5.tgz",
+ "integrity": "sha512-TMYu+DUdNlgBXING13rHSfUc3Ky5nLPbWs4bFnT+R6Vu3OvXkTkixvvBKk8uO4MT5Ab6lC3U7x8S8El2q5o56w==",
"cpu": [
"ppc64"
],
@@ -1192,9 +1268,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.18.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.18.0.tgz",
- "integrity": "sha512-ROCM7i+m1NfdrsmvwSzoxp9HFtmKGHEqu5NNDiZWQtXLA8S5HBCkVvKAxJ8U+CVctHwV2Gb5VUaK7UAkzhDjlg==",
+ "version": "4.22.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.5.tgz",
+ "integrity": "sha512-PTQq1Kz22ZRvuhr3uURH+U/Q/a0pbxJoICGSprNLAoBEkyD3Sh9qP5I0Asn0y0wejXQBbsVMRZRxlbGFD9OK4A==",
"cpu": [
"riscv64"
],
@@ -1204,9 +1280,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.18.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.18.0.tgz",
- "integrity": "sha512-0UyyRHyDN42QL+NbqevXIIUnKA47A+45WyasO+y2bGJ1mhQrfrtXUpTxCOrfxCR4esV3/RLYyucGVPiUsO8xjg==",
+ "version": "4.22.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.5.tgz",
+ "integrity": "sha512-bR5nCojtpuMss6TDEmf/jnBnzlo+6n1UhgwqUvRoe4VIotC7FG1IKkyJbwsT7JDsF2jxR+NTnuOwiGv0hLyDoQ==",
"cpu": [
"s390x"
],
@@ -1216,9 +1292,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.18.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz",
- "integrity": "sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==",
+ "version": "4.22.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.5.tgz",
+ "integrity": "sha512-N0jPPhHjGShcB9/XXZQWuWBKZQnC1F36Ce3sDqWpujsGjDz/CQtOL9LgTrJ+rJC8MJeesMWrMWVLKKNR/tMOCA==",
"cpu": [
"x64"
],
@@ -1228,9 +1304,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.18.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.0.tgz",
- "integrity": "sha512-LKaqQL9osY/ir2geuLVvRRs+utWUNilzdE90TpyoX0eNqPzWjRm14oMEE+YLve4k/NAqCdPkGYDaDF5Sw+xBfg==",
+ "version": "4.22.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.5.tgz",
+ "integrity": "sha512-uBa2e28ohzNNwjr6Uxm4XyaA1M/8aTgfF2T7UIlElLaeXkgpmIJ2EitVNQxjO9xLLLy60YqAgKn/AqSpCUkE9g==",
"cpu": [
"x64"
],
@@ -1240,9 +1316,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.18.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.18.0.tgz",
- "integrity": "sha512-7J6TkZQFGo9qBKH0pk2cEVSRhJbL6MtfWxth7Y5YmZs57Pi+4x6c2dStAUvaQkHQLnEQv1jzBUW43GvZW8OFqA==",
+ "version": "4.22.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.5.tgz",
+ "integrity": "sha512-RXT8S1HP8AFN/Kr3tg4fuYrNxZ/pZf1HemC5Tsddc6HzgGnJm0+Lh5rAHJkDuW3StI0ynNXukidROMXYl6ew8w==",
"cpu": [
"arm64"
],
@@ -1252,9 +1328,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.18.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.18.0.tgz",
- "integrity": "sha512-Txjh+IxBPbkUB9+SXZMpv+b/vnTEtFyfWZgJ6iyCmt2tdx0OF5WhFowLmnh8ENGNpfUlUZkdI//4IEmhwPieNg==",
+ "version": "4.22.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.5.tgz",
+ "integrity": "sha512-ElTYOh50InL8kzyUD6XsnPit7jYCKrphmddKAe1/Ytt74apOxDq5YEcbsiKs0fR3vff3jEneMM+3I7jbqaMyBg==",
"cpu": [
"ia32"
],
@@ -1264,9 +1340,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.18.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz",
- "integrity": "sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g==",
+ "version": "4.22.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.5.tgz",
+ "integrity": "sha512-+lvL/4mQxSV8MukpkKyyvfwhH266COcWlXE/1qxwN08ajovta3459zrjLghYMgDerlzNwLAcFpvU+WWE5y6nAQ==",
"cpu": [
"x64"
],
@@ -1413,9 +1489,9 @@
}
},
"node_modules/@types/estree": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
- "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw=="
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
+ "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
@@ -1452,6 +1528,12 @@
"parse5": "^7.0.0"
}
},
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true
+ },
"node_modules/@types/node": {
"version": "20.14.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz",
@@ -1502,16 +1584,16 @@
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "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==",
+ "version": "8.8.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.0.tgz",
+ "integrity": "sha512-wORFWjU30B2WJ/aXBfOm1LX9v9nyt9D3jsSOxC3cCaTQGCW5k4jNpmjFv3U7p/7s4yvdjHzwtv2Sd2dOyhjS0A==",
"dev": true,
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
- "@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",
+ "@typescript-eslint/scope-manager": "8.8.0",
+ "@typescript-eslint/type-utils": "8.8.0",
+ "@typescript-eslint/utils": "8.8.0",
+ "@typescript-eslint/visitor-keys": "8.8.0",
"graphemer": "^1.4.0",
"ignore": "^5.3.1",
"natural-compare": "^1.4.0",
@@ -1535,15 +1617,15 @@
}
},
"node_modules/@typescript-eslint/parser": {
- "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==",
+ "version": "8.8.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.8.0.tgz",
+ "integrity": "sha512-uEFUsgR+tl8GmzmLjRqz+VrDv4eoaMqMXW7ruXfgThaAShO9JTciKpEsB+TvnfFfbg5IpujgMXVV36gOJRLtZg==",
"dev": true,
"dependencies": {
- "@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",
+ "@typescript-eslint/scope-manager": "8.8.0",
+ "@typescript-eslint/types": "8.8.0",
+ "@typescript-eslint/typescript-estree": "8.8.0",
+ "@typescript-eslint/visitor-keys": "8.8.0",
"debug": "^4.3.4"
},
"engines": {
@@ -1563,13 +1645,13 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "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==",
+ "version": "8.8.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.8.0.tgz",
+ "integrity": "sha512-EL8eaGC6gx3jDd8GwEFEV091210U97J0jeEHrAYvIYosmEGet4wJ+g0SYmLu+oRiAwbSA5AVrt6DxLHfdd+bUg==",
"dev": true,
"dependencies": {
- "@typescript-eslint/types": "8.0.0-alpha.39",
- "@typescript-eslint/visitor-keys": "8.0.0-alpha.39"
+ "@typescript-eslint/types": "8.8.0",
+ "@typescript-eslint/visitor-keys": "8.8.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1580,13 +1662,13 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
- "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==",
+ "version": "8.8.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.8.0.tgz",
+ "integrity": "sha512-IKwJSS7bCqyCeG4NVGxnOP6lLT9Okc3Zj8hLO96bpMkJab+10HIfJbMouLrlpyOr3yrQ1cA413YPFiGd1mW9/Q==",
"dev": true,
"dependencies": {
- "@typescript-eslint/typescript-estree": "8.0.0-alpha.39",
- "@typescript-eslint/utils": "8.0.0-alpha.39",
+ "@typescript-eslint/typescript-estree": "8.8.0",
+ "@typescript-eslint/utils": "8.8.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.3.0"
},
@@ -1604,9 +1686,9 @@
}
},
"node_modules/@typescript-eslint/types": {
- "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==",
+ "version": "8.8.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.8.0.tgz",
+ "integrity": "sha512-QJwc50hRCgBd/k12sTykOJbESe1RrzmX6COk8Y525C9l7oweZ+1lw9JiU56im7Amm8swlz00DRIlxMYLizr2Vw==",
"dev": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1617,15 +1699,15 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "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==",
+ "version": "8.8.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.8.0.tgz",
+ "integrity": "sha512-ZaMJwc/0ckLz5DaAZ+pNLmHv8AMVGtfWxZe/x2JVEkD5LnmhWiQMMcYT7IY7gkdJuzJ9P14fRy28lUrlDSWYdw==",
"dev": true,
"dependencies": {
- "@typescript-eslint/types": "8.0.0-alpha.39",
- "@typescript-eslint/visitor-keys": "8.0.0-alpha.39",
+ "@typescript-eslint/types": "8.8.0",
+ "@typescript-eslint/visitor-keys": "8.8.0",
"debug": "^4.3.4",
- "globby": "^11.1.0",
+ "fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
"minimatch": "^9.0.4",
"semver": "^7.6.0",
@@ -1669,15 +1751,15 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "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==",
+ "version": "8.8.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.8.0.tgz",
+ "integrity": "sha512-QE2MgfOTem00qrlPgyByaCHay9yb1+9BjnMFnSFkUKQfu7adBXDTnCAivURnuPPAG/qiB+kzKkZKmKfaMT0zVg==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
- "@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/scope-manager": "8.8.0",
+ "@typescript-eslint/types": "8.8.0",
+ "@typescript-eslint/typescript-estree": "8.8.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1691,12 +1773,12 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "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==",
+ "version": "8.8.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.8.0.tgz",
+ "integrity": "sha512-8mq51Lx6Hpmd7HnA2fcHQo3YgfX1qbccxQOgZcb4tvasu//zXRaA1j5ZRFeCw/VRAdFi4mRM9DnZw0Nu0Q2d1g==",
"dev": true,
"dependencies": {
- "@typescript-eslint/types": "8.0.0-alpha.39",
+ "@typescript-eslint/types": "8.8.0",
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
@@ -1720,270 +1802,144 @@
}
},
"node_modules/@vitest/coverage-v8": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.0.tgz",
- "integrity": "sha512-KvapcbMY/8GYIG0rlwwOKCVNRc0OL20rrhFkg/CHNzncV03TE2XWvO5w9uZYoxNiMEBacAJt3unSOiZ7svePew==",
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz",
+ "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==",
"dev": true,
"dependencies": {
- "@ampproject/remapping": "^2.2.1",
+ "@ampproject/remapping": "^2.3.0",
"@bcoe/v8-coverage": "^0.2.3",
- "debug": "^4.3.4",
+ "debug": "^4.3.6",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
- "istanbul-lib-source-maps": "^5.0.4",
- "istanbul-reports": "^3.1.6",
- "magic-string": "^0.30.5",
- "magicast": "^0.3.3",
- "picocolors": "^1.0.0",
- "std-env": "^3.5.0",
- "strip-literal": "^2.0.0",
- "test-exclude": "^6.0.0"
+ "istanbul-lib-source-maps": "^5.0.6",
+ "istanbul-reports": "^3.1.7",
+ "magic-string": "^0.30.11",
+ "magicast": "^0.3.4",
+ "std-env": "^3.7.0",
+ "test-exclude": "^7.0.1",
+ "tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "vitest": "1.6.0"
+ "@vitest/browser": "2.1.1",
+ "vitest": "2.1.1"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
}
},
"node_modules/@vitest/expect": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.0.tgz",
- "integrity": "sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==",
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz",
+ "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==",
"dev": true,
"dependencies": {
- "@vitest/spy": "1.6.0",
- "@vitest/utils": "1.6.0",
- "chai": "^4.3.10"
+ "@vitest/spy": "2.1.1",
+ "@vitest/utils": "2.1.1",
+ "chai": "^5.1.1",
+ "tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
- "node_modules/@vitest/expect/node_modules/assertion-error": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
- "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
- "dev": true,
- "engines": {
- "node": "*"
- }
- },
- "node_modules/@vitest/expect/node_modules/chai": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz",
- "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==",
+ "node_modules/@vitest/mocker": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz",
+ "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==",
"dev": true,
"dependencies": {
- "assertion-error": "^1.1.0",
- "check-error": "^1.0.3",
- "deep-eql": "^4.1.3",
- "get-func-name": "^2.0.2",
- "loupe": "^2.3.6",
- "pathval": "^1.1.1",
- "type-detect": "^4.0.8"
+ "@vitest/spy": "^2.1.0-beta.1",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.11"
},
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/@vitest/expect/node_modules/check-error": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
- "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==",
- "dev": true,
- "dependencies": {
- "get-func-name": "^2.0.2"
+ "funding": {
+ "url": "https://opencollective.com/vitest"
},
- "engines": {
- "node": "*"
- }
- },
- "node_modules/@vitest/expect/node_modules/deep-eql": {
- "version": "4.1.4",
- "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
- "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==",
- "dev": true,
- "dependencies": {
- "type-detect": "^4.0.0"
+ "peerDependencies": {
+ "@vitest/spy": "2.1.1",
+ "msw": "^2.3.5",
+ "vite": "^5.0.0"
},
- "engines": {
- "node": ">=6"
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
}
},
- "node_modules/@vitest/expect/node_modules/loupe": {
- "version": "2.3.7",
- "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
- "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
+ "node_modules/@vitest/pretty-format": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.1.tgz",
+ "integrity": "sha512-SjxPFOtuINDUW8/UkElJYQSFtnWX7tMksSGW0vfjxMneFqxVr8YJ979QpMbDW7g+BIiq88RAGDjf7en6rvLPPQ==",
"dev": true,
"dependencies": {
- "get-func-name": "^2.0.1"
- }
- },
- "node_modules/@vitest/expect/node_modules/pathval": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
- "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
- "dev": true,
- "engines": {
- "node": "*"
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.0.tgz",
- "integrity": "sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==",
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz",
+ "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==",
"dev": true,
"dependencies": {
- "@vitest/utils": "1.6.0",
- "p-limit": "^5.0.0",
- "pathe": "^1.1.1"
+ "@vitest/utils": "2.1.1",
+ "pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
- "node_modules/@vitest/runner/node_modules/p-limit": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz",
- "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==",
- "dev": true,
- "dependencies": {
- "yocto-queue": "^1.0.0"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/@vitest/runner/node_modules/yocto-queue": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz",
- "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==",
- "dev": true,
- "engines": {
- "node": ">=12.20"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/@vitest/snapshot": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.0.tgz",
- "integrity": "sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==",
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz",
+ "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==",
"dev": true,
"dependencies": {
- "magic-string": "^0.30.5",
- "pathe": "^1.1.1",
- "pretty-format": "^29.7.0"
+ "@vitest/pretty-format": "2.1.1",
+ "magic-string": "^0.30.11",
+ "pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
- "node_modules/@vitest/snapshot/node_modules/ansi-styles": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
- "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
- "dev": true,
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/@vitest/snapshot/node_modules/pretty-format": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
- "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
- "dev": true,
- "dependencies": {
- "@jest/schemas": "^29.6.3",
- "ansi-styles": "^5.0.0",
- "react-is": "^18.0.0"
- },
- "engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- }
- },
- "node_modules/@vitest/snapshot/node_modules/react-is": {
- "version": "18.3.1",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
- "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
- "dev": true
- },
"node_modules/@vitest/spy": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.0.tgz",
- "integrity": "sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==",
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz",
+ "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==",
"dev": true,
"dependencies": {
- "tinyspy": "^2.2.0"
+ "tinyspy": "^3.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.0.tgz",
- "integrity": "sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==",
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz",
+ "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==",
"dev": true,
"dependencies": {
- "diff-sequences": "^29.6.3",
- "estree-walker": "^3.0.3",
- "loupe": "^2.3.7",
- "pretty-format": "^29.7.0"
+ "@vitest/pretty-format": "2.1.1",
+ "loupe": "^3.1.1",
+ "tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
- "node_modules/@vitest/utils/node_modules/ansi-styles": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
- "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
- "dev": true,
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/@vitest/utils/node_modules/loupe": {
- "version": "2.3.7",
- "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
- "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
- "dev": true,
- "dependencies": {
- "get-func-name": "^2.0.1"
- }
- },
- "node_modules/@vitest/utils/node_modules/pretty-format": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
- "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
- "dev": true,
- "dependencies": {
- "@jest/schemas": "^29.6.3",
- "ansi-styles": "^5.0.0",
- "react-is": "^18.0.0"
- },
- "engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- }
- },
- "node_modules/@vitest/utils/node_modules/react-is": {
- "version": "18.3.1",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
- "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
- "dev": true
- },
"node_modules/abab": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
@@ -2371,12 +2327,6 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true
},
- "node_modules/confbox": {
- "version": "0.1.7",
- "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.7.tgz",
- "integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==",
- "dev": true
- },
"node_modules/cosmiconfig": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz",
@@ -2421,9 +2371,9 @@
}
},
"node_modules/cross-fetch": {
- "version": "3.1.8",
- "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz",
- "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
+ "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
"dev": true,
"dependencies": {
"node-fetch": "^2.6.12"
@@ -2520,12 +2470,12 @@
}
},
"node_modules/debug": {
- "version": "4.3.5",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz",
- "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==",
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"dependencies": {
- "ms": "2.1.2"
+ "ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
@@ -2575,15 +2525,6 @@
"node": ">=6"
}
},
- "node_modules/diff-sequences": {
- "version": "29.6.3",
- "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
- "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
- "dev": true,
- "engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- }
- },
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -2615,6 +2556,12 @@
"node": ">=12"
}
},
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "dev": true
+ },
"node_modules/electron-to-chromium": {
"version": "1.4.816",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.816.tgz",
@@ -2735,25 +2682,29 @@
}
},
"node_modules/eslint": {
- "version": "9.6.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.6.0.tgz",
- "integrity": "sha512-ElQkdLMEEqQNM9Njff+2Y4q2afHk7JpkPvrd7Xh7xefwgQynqPxwf55J7di9+MEibWUGdNjFF9ITG9Pck5M84w==",
+ "version": "9.11.1",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.11.1.tgz",
+ "integrity": "sha512-MobhYKIoAO1s1e4VUrgx1l1Sk2JBR/Gqjjgw8+mfgoLE2xwsHur4gdfTxyTgShrhvdVFTaJSgMiQBl1jv/AWxg==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
- "@eslint-community/regexpp": "^4.6.1",
- "@eslint/config-array": "^0.17.0",
+ "@eslint-community/regexpp": "^4.11.0",
+ "@eslint/config-array": "^0.18.0",
+ "@eslint/core": "^0.6.0",
"@eslint/eslintrc": "^3.1.0",
- "@eslint/js": "9.6.0",
+ "@eslint/js": "9.11.1",
+ "@eslint/plugin-kit": "^0.2.0",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.3.0",
"@nodelib/fs.walk": "^1.2.8",
+ "@types/estree": "^1.0.6",
+ "@types/json-schema": "^7.0.15",
"ajv": "^6.12.4",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.2",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
- "eslint-scope": "^8.0.1",
+ "eslint-scope": "^8.0.2",
"eslint-visitor-keys": "^4.0.0",
"espree": "^10.1.0",
"esquery": "^1.5.0",
@@ -2767,7 +2718,6 @@
"is-glob": "^4.0.0",
"is-path-inside": "^3.0.3",
"json-stable-stringify-without-jsonify": "^1.0.1",
- "levn": "^0.4.1",
"lodash.merge": "^4.6.2",
"minimatch": "^3.1.2",
"natural-compare": "^1.4.0",
@@ -2783,6 +2733,14 @@
},
"funding": {
"url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
}
},
"node_modules/eslint-config-prettier": {
@@ -2790,8 +2748,6 @@
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
"dev": true,
- "optional": true,
- "peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -2800,13 +2756,13 @@
}
},
"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==",
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz",
+ "integrity": "sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==",
"dev": true,
"dependencies": {
"prettier-linter-helpers": "^1.0.0",
- "synckit": "^0.8.6"
+ "synckit": "^0.9.1"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
@@ -2987,9 +2943,9 @@
}
},
"node_modules/eslint-scope": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.1.tgz",
- "integrity": "sha512-pL8XjgP4ZOmmwfFE8mEhSxA7ZY4C+LWyqjQ3o4yWkkmD0qcMT9kkW3zWHOczhWcjTSgqycYAgwSlXvZltv65og==",
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.1.0.tgz",
+ "integrity": "sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==",
"dev": true,
"dependencies": {
"esrecurse": "^4.3.0",
@@ -3095,29 +3051,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/execa": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
- "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
- "dev": true,
- "dependencies": {
- "cross-spawn": "^7.0.3",
- "get-stream": "^8.0.1",
- "human-signals": "^5.0.0",
- "is-stream": "^3.0.0",
- "merge-stream": "^2.0.0",
- "npm-run-path": "^5.1.0",
- "onetime": "^6.0.0",
- "signal-exit": "^4.1.0",
- "strip-final-newline": "^3.0.0"
- },
- "engines": {
- "node": ">=16.17"
- },
- "funding": {
- "url": "https://github.com/sindresorhus/execa?sponsor=1"
- }
- },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3243,6 +3176,22 @@
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
"dev": true
},
+ "node_modules/foreground-child": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
+ "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==",
+ "dev": true,
+ "dependencies": {
+ "cross-spawn": "^7.0.0",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
@@ -3269,12 +3218,6 @@
"url": "https://github.com/sponsors/rawify"
}
},
- "node_modules/fs.realpath": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
- "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
- "dev": true
- },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3297,34 +3240,21 @@
"node": "*"
}
},
- "node_modules/get-stream": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
- "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
- "dev": true,
- "engines": {
- "node": ">=16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/glob": {
- "version": "7.2.3",
- "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
- "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
- "deprecated": "Glob versions prior to v9 are no longer supported",
+ "version": "10.4.5",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
+ "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"dev": true,
"dependencies": {
- "fs.realpath": "^1.0.0",
- "inflight": "^1.0.4",
- "inherits": "2",
- "minimatch": "^3.1.1",
- "once": "^1.3.0",
- "path-is-absolute": "^1.0.0"
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
},
- "engines": {
- "node": "*"
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -3342,6 +3272,30 @@
"node": ">=10.13.0"
}
},
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/global-modules": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz",
@@ -3495,15 +3449,6 @@
"node": ">= 14"
}
},
- "node_modules/human-signals": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
- "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
- "dev": true,
- "engines": {
- "node": ">=16.17.0"
- }
- },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -3517,9 +3462,9 @@
}
},
"node_modules/ignore": {
- "version": "5.3.1",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
- "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==",
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"engines": {
"node": ">= 4"
@@ -3559,23 +3504,6 @@
"node": ">=8"
}
},
- "node_modules/inflight": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
- "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
- "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
- "dev": true,
- "dependencies": {
- "once": "^1.3.0",
- "wrappy": "1"
- }
- },
- "node_modules/inherits": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
- "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "dev": true
- },
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
@@ -3648,18 +3576,6 @@
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true
},
- "node_modules/is-stream": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
- "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
- "dev": true,
- "engines": {
- "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3715,6 +3631,21 @@
"node": ">=8"
}
},
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+ "dev": true,
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
"node_modules/jest-environment-jsdom": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz",
@@ -4121,9 +4052,9 @@
}
},
"node_modules/known-css-properties": {
- "version": "0.31.0",
- "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.31.0.tgz",
- "integrity": "sha512-sBPIUGTNF0czz0mwGGUoKKJC8Q7On1GPbCSFPfyEsfHb2DyBG0Y4QtV+EVWpINSaiGKZblDNuF5AezxSgOhesQ==",
+ "version": "0.34.0",
+ "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.34.0.tgz",
+ "integrity": "sha512-tBECoUqNFbyAY4RrbqsBQqDFpGXAEbdD5QKr8kACx3+rnArmuuR22nKQWKazvp07N9yjTyDZaw/20UIH8tL9DQ==",
"dev": true
},
"node_modules/levn": {
@@ -4145,22 +4076,6 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true
},
- "node_modules/local-pkg": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz",
- "integrity": "sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==",
- "dev": true,
- "dependencies": {
- "mlly": "^1.4.2",
- "pkg-types": "^1.0.3"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -4203,6 +4118,12 @@
"get-func-name": "^2.0.1"
}
},
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true
+ },
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -4213,12 +4134,12 @@
}
},
"node_modules/magic-string": {
- "version": "0.30.10",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz",
- "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==",
+ "version": "0.30.11",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz",
+ "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==",
"dev": true,
"dependencies": {
- "@jridgewell/sourcemap-codec": "^1.4.15"
+ "@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/magicast": {
@@ -4275,12 +4196,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/merge-stream": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
- "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
- "dev": true
- },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -4290,9 +4205,9 @@
}
},
"node_modules/micromatch": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
- "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==",
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
@@ -4322,18 +4237,6 @@
"node": ">= 0.6"
}
},
- "node_modules/mimic-fn": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
- "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
- "dev": true,
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -4355,22 +4258,19 @@
"node": "*"
}
},
- "node_modules/mlly": {
- "version": "1.7.1",
- "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.1.tgz",
- "integrity": "sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==",
+ "node_modules/minipass": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+ "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
- "dependencies": {
- "acorn": "^8.11.3",
- "pathe": "^1.1.2",
- "pkg-types": "^1.1.1",
- "ufo": "^1.5.3"
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
}
},
"node_modules/ms": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
- "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
},
"node_modules/nanoid": {
@@ -4465,63 +4365,12 @@
"resolved": "https://registry.npmjs.org/normalize.css/-/normalize.css-8.0.1.tgz",
"integrity": "sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg=="
},
- "node_modules/npm-run-path": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
- "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
- "dev": true,
- "dependencies": {
- "path-key": "^4.0.0"
- },
- "engines": {
- "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/npm-run-path/node_modules/path-key": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
- "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
- "dev": true,
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/nwsapi": {
"version": "2.2.10",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.10.tgz",
"integrity": "sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ==",
"dev": true
},
- "node_modules/once": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
- "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
- "dev": true,
- "dependencies": {
- "wrappy": "1"
- }
- },
- "node_modules/onetime": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
- "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
- "dev": true,
- "dependencies": {
- "mimic-fn": "^4.0.0"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4569,6 +4418,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "dev": true
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -4620,15 +4475,6 @@
"node": ">=8"
}
},
- "node_modules/path-is-absolute": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
- "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
- "dev": true,
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -4637,6 +4483,22 @@
"node": ">=8"
}
},
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
@@ -4662,9 +4524,9 @@
}
},
"node_modules/picocolors": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
- "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew=="
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
+ "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="
},
"node_modules/picomatch": {
"version": "2.3.1",
@@ -4677,21 +4539,10 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
- "node_modules/pkg-types": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.1.3.tgz",
- "integrity": "sha512-+JrgthZG6m3ckicaOB74TwQ+tBWsFl3qVQg7mN8ulwSOElJ7gBhKzj2VkCPnZ4NlF6kEquYU+RIYNVAvzd54UA==",
- "dev": true,
- "dependencies": {
- "confbox": "^0.1.7",
- "mlly": "^1.7.1",
- "pathe": "^1.1.2"
- }
- },
"node_modules/postcss": {
- "version": "8.4.39",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz",
- "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==",
+ "version": "8.4.47",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
+ "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==",
"funding": [
{
"type": "opencollective",
@@ -4708,8 +4559,8 @@
],
"dependencies": {
"nanoid": "^3.3.7",
- "picocolors": "^1.0.1",
- "source-map-js": "^1.2.0"
+ "picocolors": "^1.1.0",
+ "source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -4761,9 +4612,9 @@
}
},
"node_modules/postcss-resolve-nested-selector": {
- "version": "0.1.1",
- "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.1.tgz",
- "integrity": "sha512-HvExULSwLqHLgUy1rl3ANIqCsvMS0WHss2UOsXhXnQaZ9VCc2oBvIpXrl00IUFT5ZDITME0o6oiXeiHr2SAIfw==",
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz",
+ "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==",
"dev": true
},
"node_modules/postcss-safe-parser": {
@@ -4793,9 +4644,9 @@
}
},
"node_modules/postcss-selector-parser": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.0.tgz",
- "integrity": "sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==",
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"dependencies": {
"cssesc": "^3.0.0",
@@ -4835,9 +4686,9 @@
}
},
"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==",
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
+ "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
@@ -4986,11 +4837,11 @@
}
},
"node_modules/rollup": {
- "version": "4.18.0",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.0.tgz",
- "integrity": "sha512-QmJz14PX3rzbJCN1SG4Xe/bAAX2a6NpCP8ab2vfu2GiUr8AQcr2nCV/oEO3yneFarB67zk8ShlIyWb2LGTb3Sg==",
+ "version": "4.22.5",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.5.tgz",
+ "integrity": "sha512-WoinX7GeQOFMGznEcWA1WrTQCd/tpEbMkc3nuMs9BT0CPjMdSjPMTVClwWd4pgSQwJdP65SK9mTCNvItlr5o7w==",
"dependencies": {
- "@types/estree": "1.0.5"
+ "@types/estree": "1.0.6"
},
"bin": {
"rollup": "dist/bin/rollup"
@@ -5000,22 +4851,22 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.18.0",
- "@rollup/rollup-android-arm64": "4.18.0",
- "@rollup/rollup-darwin-arm64": "4.18.0",
- "@rollup/rollup-darwin-x64": "4.18.0",
- "@rollup/rollup-linux-arm-gnueabihf": "4.18.0",
- "@rollup/rollup-linux-arm-musleabihf": "4.18.0",
- "@rollup/rollup-linux-arm64-gnu": "4.18.0",
- "@rollup/rollup-linux-arm64-musl": "4.18.0",
- "@rollup/rollup-linux-powerpc64le-gnu": "4.18.0",
- "@rollup/rollup-linux-riscv64-gnu": "4.18.0",
- "@rollup/rollup-linux-s390x-gnu": "4.18.0",
- "@rollup/rollup-linux-x64-gnu": "4.18.0",
- "@rollup/rollup-linux-x64-musl": "4.18.0",
- "@rollup/rollup-win32-arm64-msvc": "4.18.0",
- "@rollup/rollup-win32-ia32-msvc": "4.18.0",
- "@rollup/rollup-win32-x64-msvc": "4.18.0",
+ "@rollup/rollup-android-arm-eabi": "4.22.5",
+ "@rollup/rollup-android-arm64": "4.22.5",
+ "@rollup/rollup-darwin-arm64": "4.22.5",
+ "@rollup/rollup-darwin-x64": "4.22.5",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.22.5",
+ "@rollup/rollup-linux-arm-musleabihf": "4.22.5",
+ "@rollup/rollup-linux-arm64-gnu": "4.22.5",
+ "@rollup/rollup-linux-arm64-musl": "4.22.5",
+ "@rollup/rollup-linux-powerpc64le-gnu": "4.22.5",
+ "@rollup/rollup-linux-riscv64-gnu": "4.22.5",
+ "@rollup/rollup-linux-s390x-gnu": "4.22.5",
+ "@rollup/rollup-linux-x64-gnu": "4.22.5",
+ "@rollup/rollup-linux-x64-musl": "4.22.5",
+ "@rollup/rollup-win32-arm64-msvc": "4.22.5",
+ "@rollup/rollup-win32-ia32-msvc": "4.22.5",
+ "@rollup/rollup-win32-x64-msvc": "4.22.5",
"fsevents": "~2.3.2"
}
},
@@ -5151,9 +5002,9 @@
}
},
"node_modules/source-map-js": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
- "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"engines": {
"node": ">=0.10.0"
}
@@ -5205,6 +5056,21 @@
"node": ">=8"
}
},
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@@ -5217,16 +5083,17 @@
"node": ">=8"
}
},
- "node_modules/strip-final-newline": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
- "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
- "engines": {
- "node": ">=12"
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
},
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "engines": {
+ "node": ">=8"
}
},
"node_modules/strip-indent": {
@@ -5253,28 +5120,10 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/strip-literal": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.0.tgz",
- "integrity": "sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw==",
- "dev": true,
- "dependencies": {
- "js-tokens": "^9.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/strip-literal/node_modules/js-tokens": {
- "version": "9.0.0",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.0.tgz",
- "integrity": "sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ==",
- "dev": true
- },
"node_modules/stylelint": {
- "version": "16.6.1",
- "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.6.1.tgz",
- "integrity": "sha512-yNgz2PqWLkhH2hw6X9AweV9YvoafbAD5ZsFdKN9BvSDVwGvPh+AUIrn7lYwy1S7IHmtFin75LLfX1m0D2tHu8Q==",
+ "version": "16.9.0",
+ "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.9.0.tgz",
+ "integrity": "sha512-31Nm3WjxGOBGpQqF43o3wO9L5AC36TPIe6030Lnm13H3vDMTcS21DrLh69bMX+DBilKqMMVLian4iG6ybBoNRQ==",
"dev": true,
"funding": [
{
@@ -5287,17 +5136,17 @@
}
],
"dependencies": {
- "@csstools/css-parser-algorithms": "^2.6.3",
- "@csstools/css-tokenizer": "^2.3.1",
- "@csstools/media-query-list-parser": "^2.1.11",
- "@csstools/selector-specificity": "^3.1.1",
+ "@csstools/css-parser-algorithms": "^3.0.1",
+ "@csstools/css-tokenizer": "^3.0.1",
+ "@csstools/media-query-list-parser": "^3.0.1",
+ "@csstools/selector-specificity": "^4.0.0",
"@dual-bundle/import-meta-resolve": "^4.1.0",
"balanced-match": "^2.0.0",
"colord": "^2.9.3",
"cosmiconfig": "^9.0.0",
"css-functions-list": "^3.2.2",
"css-tree": "^2.3.1",
- "debug": "^4.3.4",
+ "debug": "^4.3.6",
"fast-glob": "^3.3.2",
"fastest-levenshtein": "^1.0.16",
"file-entry-cache": "^9.0.0",
@@ -5305,24 +5154,24 @@
"globby": "^11.1.0",
"globjoin": "^0.1.4",
"html-tags": "^3.3.1",
- "ignore": "^5.3.1",
+ "ignore": "^5.3.2",
"imurmurhash": "^0.1.4",
"is-plain-object": "^5.0.0",
- "known-css-properties": "^0.31.0",
+ "known-css-properties": "^0.34.0",
"mathml-tag-names": "^2.1.3",
"meow": "^13.2.0",
- "micromatch": "^4.0.7",
+ "micromatch": "^4.0.8",
"normalize-path": "^3.0.0",
"picocolors": "^1.0.1",
- "postcss": "^8.4.38",
- "postcss-resolve-nested-selector": "^0.1.1",
+ "postcss": "^8.4.41",
+ "postcss-resolve-nested-selector": "^0.1.6",
"postcss-safe-parser": "^7.0.0",
- "postcss-selector-parser": "^6.1.0",
+ "postcss-selector-parser": "^6.1.2",
"postcss-value-parser": "^4.2.0",
"resolve-from": "^5.0.0",
"string-width": "^4.2.3",
"strip-ansi": "^7.1.0",
- "supports-hyperlinks": "^3.0.0",
+ "supports-hyperlinks": "^3.1.0",
"svg-tags": "^1.0.0",
"table": "^6.8.2",
"write-file-atomic": "^5.0.1"
@@ -5397,6 +5246,70 @@
"stylelint": ">=16.0.0"
}
},
+ "node_modules/stylelint/node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.1.tgz",
+ "integrity": "sha512-lSquqZCHxDfuTg/Sk2hiS0mcSFCEBuj49JfzPHJogDBT0mGCyY5A1AQzBWngitrp7i1/HAZpIgzF/VjhOEIJIg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.1"
+ }
+ },
+ "node_modules/stylelint/node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.1.tgz",
+ "integrity": "sha512-UBqaiu7kU0lfvaP982/o3khfXccVlHPWp0/vwwiIgDF0GmqqqxoiXC/6FCjlS9u92f7CoEz6nXKQnrn1kIAkOw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/stylelint/node_modules/@csstools/media-query-list-parser": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-3.0.1.tgz",
+ "integrity": "sha512-HNo8gGD02kHmcbX6PvCoUuOQvn4szyB9ca63vZHKX5A81QytgDG4oxG4IaEfHTlEZSZ6MjPEMWIVU+zF2PZcgw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.1",
+ "@csstools/css-tokenizer": "^3.0.1"
+ }
+ },
"node_modules/stylelint/node_modules/ansi-regex": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
@@ -5492,9 +5405,9 @@
}
},
"node_modules/supports-hyperlinks": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.0.0.tgz",
- "integrity": "sha512-QBDPHyPQDRTy9ku4URNGY5Lah8PAaXs6tAAwp55sL5WCsSW7GIfdf6W5ixfziW+t7wh3GVvHyHHyQ1ESsoRvaA==",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.1.0.tgz",
+ "integrity": "sha512-2rn0BZ+/f7puLOHZm1HOJfwBggfaHXUpPUSSG/SWM4TWp5KCfmNYwnC3hruy2rZlMnmWZ+QAGpZfchu3f3695A==",
"dev": true,
"dependencies": {
"has-flag": "^4.0.0",
@@ -5502,6 +5415,9 @@
},
"engines": {
"node": ">=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/svg-tags": {
@@ -5517,9 +5433,9 @@
"dev": true
},
"node_modules/synckit": {
- "version": "0.8.8",
- "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
- "integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==",
+ "version": "0.9.1",
+ "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.1.tgz",
+ "integrity": "sha512-7gr8p9TQP6RAHusBOSLs46F4564ZrjV8xFmw5zCmgmhGUcw2hxsShhJ6CEiHQMgPDwAQ1fWHPM0ypc4RMAig4A==",
"dev": true,
"dependencies": {
"@pkgr/core": "^0.1.0",
@@ -5571,17 +5487,41 @@
"dev": true
},
"node_modules/test-exclude": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
- "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
+ "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
"dev": true,
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
- "glob": "^7.1.4",
- "minimatch": "^3.0.4"
+ "glob": "^10.4.1",
+ "minimatch": "^9.0.4"
},
"engines": {
- "node": ">=8"
+ "node": ">=18"
+ }
+ },
+ "node_modules/test-exclude/node_modules/brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/test-exclude/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/text-table": {
@@ -5591,24 +5531,39 @@
"dev": true
},
"node_modules/tinybench": {
- "version": "2.8.0",
- "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz",
- "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==",
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz",
+ "integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==",
"dev": true
},
"node_modules/tinypool": {
- "version": "0.8.4",
- "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz",
- "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.1.tgz",
+ "integrity": "sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==",
+ "dev": true,
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
+ "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
"dev": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
- "version": "2.2.1",
- "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz",
- "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==",
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
+ "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
"dev": true,
"engines": {
"node": ">=14.0.0"
@@ -5674,9 +5629,9 @@
}
},
"node_modules/tslib": {
- "version": "2.6.3",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
- "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==",
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
+ "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
"dev": true
},
"node_modules/type-check": {
@@ -5713,14 +5668,14 @@
}
},
"node_modules/typescript-eslint": {
- "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==",
+ "version": "8.8.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.8.0.tgz",
+ "integrity": "sha512-BjIT/VwJ8+0rVO01ZQ2ZVnjE1svFBiRczcpr1t1Yxt7sT25VSbPfrJtDsQ8uQTy2pilX5nI9gwxhUyLULNentw==",
"dev": true,
"dependencies": {
- "@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"
+ "@typescript-eslint/eslint-plugin": "8.8.0",
+ "@typescript-eslint/parser": "8.8.0",
+ "@typescript-eslint/utils": "8.8.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -5735,12 +5690,6 @@
}
}
},
- "node_modules/ufo": {
- "version": "1.5.3",
- "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.3.tgz",
- "integrity": "sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==",
- "dev": true
- },
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
@@ -5811,13 +5760,13 @@
"dev": true
},
"node_modules/vite": {
- "version": "5.3.3",
- "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.3.tgz",
- "integrity": "sha512-NPQdeCU0Dv2z5fu+ULotpuq5yfCS1BzKUIPhNbP3YBfAMGJXbt2nS+sbTFu+qchaqWTD+H3JK++nRwr6XIcp6A==",
+ "version": "5.4.8",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz",
+ "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==",
"dependencies": {
"esbuild": "^0.21.3",
- "postcss": "^8.4.39",
- "rollup": "^4.13.0"
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
@@ -5836,6 +5785,7 @@
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
+ "sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
@@ -5853,6 +5803,9 @@
"sass": {
"optional": true
},
+ "sass-embedded": {
+ "optional": true
+ },
"stylus": {
"optional": true
},
@@ -5865,15 +5818,14 @@
}
},
"node_modules/vite-node": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.0.tgz",
- "integrity": "sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==",
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz",
+ "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==",
"dev": true,
"dependencies": {
"cac": "^6.7.14",
- "debug": "^4.3.4",
- "pathe": "^1.1.1",
- "picocolors": "^1.0.0",
+ "debug": "^4.3.6",
+ "pathe": "^1.1.2",
"vite": "^5.0.0"
},
"bin": {
@@ -5887,31 +5839,30 @@
}
},
"node_modules/vitest": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.0.tgz",
- "integrity": "sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==",
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz",
+ "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==",
"dev": true,
"dependencies": {
- "@vitest/expect": "1.6.0",
- "@vitest/runner": "1.6.0",
- "@vitest/snapshot": "1.6.0",
- "@vitest/spy": "1.6.0",
- "@vitest/utils": "1.6.0",
- "acorn-walk": "^8.3.2",
- "chai": "^4.3.10",
- "debug": "^4.3.4",
- "execa": "^8.0.1",
- "local-pkg": "^0.5.0",
- "magic-string": "^0.30.5",
- "pathe": "^1.1.1",
- "picocolors": "^1.0.0",
- "std-env": "^3.5.0",
- "strip-literal": "^2.0.0",
- "tinybench": "^2.5.1",
- "tinypool": "^0.8.3",
+ "@vitest/expect": "2.1.1",
+ "@vitest/mocker": "2.1.1",
+ "@vitest/pretty-format": "^2.1.1",
+ "@vitest/runner": "2.1.1",
+ "@vitest/snapshot": "2.1.1",
+ "@vitest/spy": "2.1.1",
+ "@vitest/utils": "2.1.1",
+ "chai": "^5.1.1",
+ "debug": "^4.3.6",
+ "magic-string": "^0.30.11",
+ "pathe": "^1.1.2",
+ "std-env": "^3.7.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.0",
+ "tinypool": "^1.0.0",
+ "tinyrainbow": "^1.2.0",
"vite": "^5.0.0",
- "vite-node": "1.6.0",
- "why-is-node-running": "^2.2.2"
+ "vite-node": "2.1.1",
+ "why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
@@ -5925,8 +5876,8 @@
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
- "@vitest/browser": "1.6.0",
- "@vitest/ui": "1.6.0",
+ "@vitest/browser": "2.1.1",
+ "@vitest/ui": "2.1.1",
"happy-dom": "*",
"jsdom": "*"
},
@@ -5952,87 +5903,18 @@
}
},
"node_modules/vitest-fetch-mock": {
- "version": "0.2.2",
- "resolved": "https://registry.npmjs.org/vitest-fetch-mock/-/vitest-fetch-mock-0.2.2.tgz",
- "integrity": "sha512-XmH6QgTSjCWrqXoPREIdbj40T7i1xnGmAsTAgfckoO75W1IEHKR8hcPCQ7SO16RsdW1t85oUm6pcQRLeBgjVYQ==",
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/vitest-fetch-mock/-/vitest-fetch-mock-0.3.0.tgz",
+ "integrity": "sha512-g6upWcL8/32fXL43/5f4VHcocuwQIi9Fj5othcK9gPO8XqSEGtnIZdenr2IaipDr61ReRFt+vaOEgo8jiUUX5w==",
"dev": true,
"dependencies": {
- "cross-fetch": "^3.0.6"
+ "cross-fetch": "^4.0.0"
},
"engines": {
"node": ">=14.14.0"
},
"peerDependencies": {
- "vitest": ">=0.16.0"
- }
- },
- "node_modules/vitest/node_modules/assertion-error": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
- "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
- "dev": true,
- "engines": {
- "node": "*"
- }
- },
- "node_modules/vitest/node_modules/chai": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz",
- "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==",
- "dev": true,
- "dependencies": {
- "assertion-error": "^1.1.0",
- "check-error": "^1.0.3",
- "deep-eql": "^4.1.3",
- "get-func-name": "^2.0.2",
- "loupe": "^2.3.6",
- "pathval": "^1.1.1",
- "type-detect": "^4.0.8"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/vitest/node_modules/check-error": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
- "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==",
- "dev": true,
- "dependencies": {
- "get-func-name": "^2.0.2"
- },
- "engines": {
- "node": "*"
- }
- },
- "node_modules/vitest/node_modules/deep-eql": {
- "version": "4.1.4",
- "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
- "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==",
- "dev": true,
- "dependencies": {
- "type-detect": "^4.0.0"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/vitest/node_modules/loupe": {
- "version": "2.3.7",
- "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
- "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
- "dev": true,
- "dependencies": {
- "get-func-name": "^2.0.1"
- }
- },
- "node_modules/vitest/node_modules/pathval": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
- "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
- "dev": true,
- "engines": {
- "node": "*"
+ "vitest": ">=2.0.0"
}
},
"node_modules/w3c-xmlserializer": {
@@ -6105,9 +5987,9 @@
}
},
"node_modules/why-is-node-running": {
- "version": "2.2.2",
- "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz",
- "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==",
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"dependencies": {
"siginfo": "^2.0.0",
@@ -6129,12 +6011,103 @@
"node": ">=0.10.0"
}
},
- "node_modules/wrappy": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
- "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-regex": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
+ "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+ "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true
},
+ "node_modules/wrap-ansi/node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/strip-ansi": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+ "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
"node_modules/write-file-atomic": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz",
diff --git a/assets/package.json b/assets/package.json
index 1dbf4399..203f15a6 100644
--- a/assets/package.json
+++ b/assets/package.json
@@ -20,25 +20,26 @@
"postcss-mixins": "^10.0.1",
"postcss-simple-vars": "^7.0.1",
"typescript": "^5.4",
- "vite": "^5.2"
+ "vite": "^5.4"
},
"devDependencies": {
"@testing-library/dom": "^10.1.0",
"@testing-library/jest-dom": "^6.4.6",
"@types/chai-dom": "^1.11.3",
- "@vitest/coverage-v8": "^1.6.0",
+ "@vitest/coverage-v8": "^2.1.0",
"chai": "^5",
- "eslint": "^9.4.0",
- "eslint-plugin-prettier": "^5.1.3",
+ "eslint": "^9.11.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-vitest": "^0.5.4",
"jest-environment-jsdom": "^29.7.0",
"jsdom": "^24.1.0",
- "prettier": "^3.3.2",
- "stylelint": "^16.6.1",
+ "prettier": "^3.3.3",
+ "stylelint": "^16.9.0",
"stylelint-config-standard": "^36.0.0",
"stylelint-prettier": "^5.0.0",
- "typescript-eslint": "8.0.0-alpha.39",
- "vitest": "^1.6.0",
- "vitest-fetch-mock": "^0.2.2"
+ "typescript-eslint": "8.8.0",
+ "vitest": "^2.1.0",
+ "vitest-fetch-mock": "^0.3.0"
}
}
diff --git a/assets/test/fix-event-listeners.ts b/assets/test/fix-event-listeners.ts
index 7a4b07e5..899d7014 100644
--- a/assets/test/fix-event-listeners.ts
+++ b/assets/test/fix-event-listeners.ts
@@ -8,7 +8,7 @@ export function fixEventListeners(t: EventTarget) {
eventListeners = {};
const oldAddEventListener = t.addEventListener;
- t.addEventListener = (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);
diff --git a/assets/test/vitest-setup.ts b/assets/test/vitest-setup.ts
index ae3a62f8..27688447 100644
--- a/assets/test/vitest-setup.ts
+++ b/assets/test/vitest-setup.ts
@@ -31,7 +31,6 @@ Object.assign(globalThis, { URL, Blob });
// Prevents an error when calling `form.submit()` directly in
// the code that is being tested
-// eslint-disable-next-line prettier/prettier
-HTMLFormElement.prototype.submit = function() {
+HTMLFormElement.prototype.submit = function () {
fireEvent.submit(this);
};
diff --git a/assets/vite.config.ts b/assets/vite.config.ts
index 812f7f8b..f05a82c9 100644
--- a/assets/vite.config.ts
+++ b/assets/vite.config.ts
@@ -14,7 +14,9 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
fs.readdirSync(path.resolve(__dirname, 'css/themes/')).forEach(name => {
const m = name.match(/([-a-z]+).css/);
- if (m) targets.set(`css/${m[1]}`, `./css/themes/${m[1]}.css`);
+ if (m) return targets.set(`css/${m[1]}`, `./css/themes/${m[1]}.css`);
+
+ return null;
});
fs.readdirSync(path.resolve(__dirname, 'css/options/')).forEach(name => {
@@ -66,13 +68,13 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
test: {
globals: true,
environment: 'jsdom',
+ exclude: ['node_modules/', '.*\\.test\\.ts$', '.*\\.d\\.ts$', '.*\\.spec\\.ts$'],
// TODO Jest --randomize CLI flag equivalent, consider enabling in the future
// sequence: { shuffle: true },
setupFiles: './test/vitest-setup.ts',
coverage: {
reporter: ['text', 'html'],
include: ['js/**/*.{js,ts}'],
- exclude: ['node_modules/', '.*\\.test\\.ts$', '.*\\.d\\.ts$'],
thresholds: {
statements: 0,
branches: 0,
diff --git a/docker-compose.yml b/docker-compose.yml
index 29853696..5b102408 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -59,7 +59,7 @@ services:
- '5173:5173'
postgres:
- image: postgres:16.3-alpine
+ image: postgres:16.4-alpine
environment:
- POSTGRES_PASSWORD=postgres
volumes:
@@ -68,7 +68,7 @@ services:
driver: "none"
opensearch:
- image: opensearchproject/opensearch:2.15.0
+ image: opensearchproject/opensearch:2.16.0
volumes:
- opensearch_data:/usr/share/opensearch/data
- ./docker/opensearch/opensearch.yml:/usr/share/opensearch/config/opensearch.yml
@@ -80,12 +80,12 @@ services:
hard: 65536
valkey:
- image: valkey/valkey:7.2.5-alpine
+ image: valkey/valkey:8.0-alpine
logging:
driver: "none"
files:
- image: andrewgaul/s3proxy:sha-4175022
+ image: andrewgaul/s3proxy:sha-4976e17
environment:
- JCLOUDS_FILESYSTEM_BASEDIR=/srv/philomena/priv/s3
volumes:
diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile
index edb02075..1b9a8d69 100644
--- a/docker/app/Dockerfile
+++ b/docker/app/Dockerfile
@@ -1,4 +1,4 @@
-FROM elixir:1.17-alpine
+FROM elixir:1.17.2-alpine
ADD https://api.github.com/repos/philomena-dev/FFmpeg/git/refs/heads/release/6.1 /tmp/ffmpeg_version.json
RUN (echo "https://github.com/philomena-dev/prebuilt-ffmpeg/raw/master"; cat /etc/apk/repositories) > /tmp/repositories \
diff --git a/docker/web/aws-signature.lua b/docker/web/aws-signature.lua
index fae28992..31a46f58 100644
--- a/docker/web/aws-signature.lua
+++ b/docker/web/aws-signature.lua
@@ -76,7 +76,7 @@ end
local function get_hashed_canonical_request(timestamp, host, uri)
local digest = get_sha256_digest(ngx.var.request_body)
- local canonical_request = ngx.var.request_method .. '\n'
+ local canonical_request = 'GET' .. '\n'
.. uri .. '\n'
.. '\n'
.. 'host:' .. host .. '\n'
diff --git a/docker/web/nginx.conf b/docker/web/nginx.conf
index 218fe896..73bd5aea 100644
--- a/docker/web/nginx.conf
+++ b/docker/web/nginx.conf
@@ -34,7 +34,7 @@ init_by_lua_block {
function sign_aws_request()
-- The API token used should not allow writing, but
-- sanitize this anyway to stop an upstream error
- if ngx.req.get_method() ~= 'GET' then
+ if ngx.req.get_method() ~= 'GET' and ngx.req.get_method() ~= 'HEAD' then
ngx.status = ngx.HTTP_UNAUTHORIZED
ngx.say('Unauthorized')
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
diff --git a/index/images.mk b/index/images.mk
index 2ed13496..a96f446a 100644
--- a/index/images.mk
+++ b/index/images.mk
@@ -42,6 +42,7 @@ metadata: image_search_json
'processed', processed,
'score', score,
'size', image_size,
+ 'orig_size', image_orig_size,
'sha512_hash', image_sha512_hash,
'thumbnails_generated', thumbnails_generated,
'updated_at', updated_at,
diff --git a/index/posts.mk b/index/posts.mk
index 4d530713..8324d633 100644
--- a/index/posts.mk
+++ b/index/posts.mk
@@ -21,8 +21,8 @@ metadata: post_search_json
'body', p.body,
'subject', t.title,
'ip', p.ip,
- 'user_agent', p.user_agent,
- 'referrer', p.referrer,
+ 'user_agent', '',
+ 'referrer', '',
'fingerprint', p.fingerprint,
'topic_position', p.topic_position,
'forum', f.short_name,
diff --git a/lib/philomena/adverts.ex b/lib/philomena/adverts.ex
index f1794d8d..a6e4c31f 100644
--- a/lib/philomena/adverts.ex
+++ b/lib/philomena/adverts.ex
@@ -121,7 +121,7 @@ defmodule Philomena.Adverts do
"""
def create_advert(attrs \\ %{}) do
%Advert{}
- |> Advert.save_changeset(attrs)
+ |> Advert.changeset(attrs)
|> Uploader.analyze_upload(attrs)
|> Repo.insert()
|> case do
@@ -150,7 +150,7 @@ defmodule Philomena.Adverts do
"""
def update_advert(%Advert{} = advert, attrs) do
advert
- |> Advert.save_changeset(attrs)
+ |> Advert.changeset(attrs)
|> Repo.update()
end
diff --git a/lib/philomena/adverts/advert.ex b/lib/philomena/adverts/advert.ex
index 87e25379..7150f043 100644
--- a/lib/philomena/adverts/advert.ex
+++ b/lib/philomena/adverts/advert.ex
@@ -2,8 +2,6 @@ defmodule Philomena.Adverts.Advert do
use Ecto.Schema
import Ecto.Changeset
- alias Philomena.Schema.Time
-
schema "adverts" do
field :image, :string
field :link, :string
@@ -11,8 +9,8 @@ defmodule Philomena.Adverts.Advert do
field :clicks, :integer, default: 0
field :impressions, :integer, default: 0
field :live, :boolean, default: false
- field :start_date, :utc_datetime
- field :finish_date, :utc_datetime
+ field :start_date, PhilomenaQuery.Ecto.RelativeDate
+ field :finish_date, PhilomenaQuery.Ecto.RelativeDate
field :restrictions, :string
field :notes, :string
@@ -24,29 +22,18 @@ defmodule Philomena.Adverts.Advert do
field :uploaded_image, :string, virtual: true
field :removed_image, :string, virtual: true
- field :start_time, :string, virtual: true
- field :finish_time, :string, virtual: true
-
timestamps(inserted_at: :created_at, type: :utc_datetime)
end
@doc false
def changeset(advert, attrs) do
advert
- |> cast(attrs, [])
- |> Time.propagate_time(:start_date, :start_time)
- |> Time.propagate_time(:finish_date, :finish_time)
- end
-
- def save_changeset(advert, attrs) do
- advert
- |> cast(attrs, [:title, :link, :start_time, :finish_time, :live, :restrictions, :notes])
- |> Time.assign_time(:start_time, :start_date)
- |> Time.assign_time(:finish_time, :finish_date)
+ |> cast(attrs, [:title, :link, :start_date, :finish_date, :live, :restrictions, :notes])
|> validate_required([:title, :link, :start_date, :finish_date])
|> validate_inclusion(:restrictions, ["none", "nsfw", "sfw"])
end
+ @doc false
def image_changeset(advert, attrs) do
advert
|> cast(attrs, [
diff --git a/lib/philomena/adverts/recorder.ex b/lib/philomena/adverts/recorder.ex
index 19e15cf5..15bc3793 100644
--- a/lib/philomena/adverts/recorder.ex
+++ b/lib/philomena/adverts/recorder.ex
@@ -4,7 +4,7 @@ defmodule Philomena.Adverts.Recorder do
import Ecto.Query
def run(%{impressions: impressions, clicks: clicks}) do
- now = DateTime.utc_now() |> DateTime.truncate(:second)
+ now = DateTime.utc_now(:second)
# Create insert statements for Ecto
impressions = Enum.map(impressions, &impressions_insert_all(&1, now))
diff --git a/lib/philomena/artist_links.ex b/lib/philomena/artist_links.ex
index 9a9de4a6..28f468a9 100644
--- a/lib/philomena/artist_links.ex
+++ b/lib/philomena/artist_links.ex
@@ -93,7 +93,7 @@ defmodule Philomena.ArtistLinks do
Multi.new()
|> Multi.update(:artist_link, artist_link_changeset)
- |> Multi.run(:add_award, fn _repo, _changes -> BadgeAwarder.award_badge(artist_link) end)
+ |> Multi.run(:add_award, BadgeAwarder.award_callback(artist_link, verifying_user))
|> Repo.transaction()
|> case do
{:ok, %{artist_link: artist_link}} ->
diff --git a/lib/philomena/artist_links/artist_link.ex b/lib/philomena/artist_links/artist_link.ex
index d42ede90..df37458a 100644
--- a/lib/philomena/artist_links/artist_link.ex
+++ b/lib/philomena/artist_links/artist_link.ex
@@ -15,8 +15,6 @@ defmodule Philomena.ArtistLinks.ArtistLink do
field :aasm_state, :string, default: "unverified"
field :uri, :string
- field :hostname, :string
- field :path, :string
field :verification_code, :string
field :public, :boolean, default: true
field :next_check_at, :utc_datetime
@@ -37,7 +35,6 @@ defmodule Philomena.ArtistLinks.ArtistLink do
|> cast(attrs, [:uri, :public])
|> put_change(:tag_id, nil)
|> validate_required([:user, :uri, :public])
- |> parse_uri()
end
def edit_changeset(artist_link, attrs, tag) do
@@ -45,7 +42,6 @@ defmodule Philomena.ArtistLinks.ArtistLink do
|> cast(attrs, [:uri, :public])
|> put_change(:tag_id, tag.id)
|> validate_required([:user, :uri, :public])
- |> parse_uri()
end
def creation_changeset(artist_link, attrs, user, tag) do
@@ -57,7 +53,6 @@ defmodule Philomena.ArtistLinks.ArtistLink do
|> validate_required([:tag], message: "must exist")
|> validate_format(:uri, ~r|\Ahttps?://|)
|> validate_category()
- |> parse_uri()
|> put_verification_code()
|> put_next_check_at()
|> unique_constraint([:uri, :tag_id, :user_id],
@@ -90,22 +85,13 @@ defmodule Philomena.ArtistLinks.ArtistLink do
end
def contact_changeset(artist_link, user) do
- now = DateTime.utc_now() |> DateTime.truncate(:second)
-
- change(artist_link)
+ artist_link
+ |> change()
|> put_change(:contacted_by_user_id, user.id)
- |> put_change(:contacted_at, now)
+ |> put_change(:contacted_at, DateTime.utc_now(:second))
|> put_change(:aasm_state, "contacted")
end
- defp parse_uri(changeset) do
- string_uri = get_field(changeset, :uri) |> to_string()
- uri = URI.parse(string_uri)
-
- changeset
- |> change(hostname: uri.host, path: uri.path)
- end
-
defp put_verification_code(changeset) do
code = :crypto.strong_rand_bytes(5) |> Base.encode16()
change(changeset, verification_code: "#{gettext("PHILOMENA-LINKVALIDATION")}-#{code}")
@@ -113,9 +99,9 @@ defmodule Philomena.ArtistLinks.ArtistLink do
defp put_next_check_at(changeset) do
time =
- DateTime.utc_now()
+ :second
+ |> DateTime.utc_now()
|> DateTime.add(60 * 2, :second)
- |> DateTime.truncate(:second)
change(changeset, next_check_at: time)
end
diff --git a/lib/philomena/artist_links/badge_awarder.ex b/lib/philomena/artist_links/badge_awarder.ex
index ae231c74..275a5aee 100644
--- a/lib/philomena/artist_links/badge_awarder.ex
+++ b/lib/philomena/artist_links/badge_awarder.ex
@@ -16,13 +16,22 @@ defmodule Philomena.ArtistLinks.BadgeAwarder do
Returns `{:ok, award}`, `{:ok, nil}`, or `{:error, changeset}`. The return value is
suitable for use as the return value to an `Ecto.Multi.run/3` callback.
"""
- def award_badge(artist_link) do
+ def award_badge(artist_link, verifying_user) do
with badge when not is_nil(badge) <- Badges.get_badge_by_title(@badge_title),
award when is_nil(award) <- Badges.get_badge_award_for(badge, artist_link.user) do
- Badges.create_badge_award(artist_link.user, artist_link.user, %{badge_id: badge.id})
+ Badges.create_badge_award(verifying_user, artist_link.user, %{badge_id: badge.id})
else
_ ->
{:ok, nil}
end
end
+
+ @doc """
+ Get a callback for issuing a badge award from within an `m:Ecto.Multi`.
+ """
+ def award_callback(artist_link, verifying_user) do
+ fn _repo, _changes ->
+ award_badge(artist_link, verifying_user)
+ end
+ end
end
diff --git a/lib/philomena/badges/award.ex b/lib/philomena/badges/award.ex
index 0ee8da28..e6ca3bef 100644
--- a/lib/philomena/badges/award.ex
+++ b/lib/philomena/badges/award.ex
@@ -26,9 +26,7 @@ defmodule Philomena.Badges.Award do
end
defp put_awarded_on(%{data: %{awarded_on: nil}} = changeset) do
- now = DateTime.utc_now() |> DateTime.truncate(:second)
-
- put_change(changeset, :awarded_on, now)
+ put_change(changeset, :awarded_on, DateTime.utc_now(:second))
end
defp put_awarded_on(changeset), do: changeset
diff --git a/lib/philomena/bans.ex b/lib/philomena/bans.ex
index 4b4bdcc8..50830e90 100644
--- a/lib/philomena/bans.ex
+++ b/lib/philomena/bans.ex
@@ -56,7 +56,7 @@ defmodule Philomena.Bans do
"""
def create_fingerprint(creator, attrs \\ %{}) do
%Fingerprint{banning_user_id: creator.id}
- |> Fingerprint.save_changeset(attrs)
+ |> Fingerprint.changeset(attrs)
|> Repo.insert()
end
@@ -74,7 +74,7 @@ defmodule Philomena.Bans do
"""
def update_fingerprint(%Fingerprint{} = fingerprint, attrs) do
fingerprint
- |> Fingerprint.save_changeset(attrs)
+ |> Fingerprint.changeset(attrs)
|> Repo.update()
end
@@ -150,7 +150,7 @@ defmodule Philomena.Bans do
"""
def create_subnet(creator, attrs \\ %{}) do
%Subnet{banning_user_id: creator.id}
- |> Subnet.save_changeset(attrs)
+ |> Subnet.changeset(attrs)
|> Repo.insert()
end
@@ -168,7 +168,7 @@ defmodule Philomena.Bans do
"""
def update_subnet(%Subnet{} = subnet, attrs) do
subnet
- |> Subnet.save_changeset(attrs)
+ |> Subnet.changeset(attrs)
|> Repo.update()
end
@@ -245,7 +245,7 @@ defmodule Philomena.Bans do
def create_user(creator, attrs \\ %{}) do
changeset =
%User{banning_user_id: creator.id}
- |> User.save_changeset(attrs)
+ |> User.changeset(attrs)
Multi.new()
|> Multi.insert(:user_ban, changeset)
@@ -276,7 +276,7 @@ defmodule Philomena.Bans do
"""
def update_user(%User{} = user, attrs) do
user
- |> User.save_changeset(attrs)
+ |> User.changeset(attrs)
|> Repo.update()
end
diff --git a/lib/philomena/bans/fingerprint.ex b/lib/philomena/bans/fingerprint.ex
index 108fc024..5b499554 100644
--- a/lib/philomena/bans/fingerprint.ex
+++ b/lib/philomena/bans/fingerprint.ex
@@ -1,10 +1,9 @@
defmodule Philomena.Bans.Fingerprint do
use Ecto.Schema
import Ecto.Changeset
+ import Philomena.Bans.IdGenerator
alias Philomena.Users.User
- alias Philomena.Schema.Time
- alias Philomena.Schema.BanId
schema "fingerprint_bans" do
belongs_to :banning_user, User
@@ -12,27 +11,18 @@ defmodule Philomena.Bans.Fingerprint do
field :reason, :string
field :note, :string
field :enabled, :boolean, default: true
- field :valid_until, :utc_datetime
+ field :valid_until, PhilomenaQuery.Ecto.RelativeDate
field :fingerprint, :string
field :generated_ban_id, :string
- field :until, :string, virtual: true
-
timestamps(inserted_at: :created_at, type: :utc_datetime)
end
@doc false
def changeset(fingerprint_ban, attrs) do
fingerprint_ban
- |> cast(attrs, [])
- |> Time.propagate_time(:valid_until, :until)
- end
-
- def save_changeset(fingerprint_ban, attrs) do
- fingerprint_ban
- |> cast(attrs, [:reason, :note, :enabled, :fingerprint, :until])
- |> Time.assign_time(:until, :valid_until)
- |> BanId.put_ban_id("F")
+ |> cast(attrs, [:reason, :note, :enabled, :fingerprint, :valid_until])
+ |> put_ban_id("F")
|> validate_required([:reason, :enabled, :fingerprint, :valid_until])
|> check_constraint(:valid_until, name: :fingerprint_ban_duration_must_be_valid)
end
diff --git a/lib/philomena/schema/ban_id.ex b/lib/philomena/bans/id_generator.ex
similarity index 82%
rename from lib/philomena/schema/ban_id.ex
rename to lib/philomena/bans/id_generator.ex
index c1c8ee02..e2b7cf03 100644
--- a/lib/philomena/schema/ban_id.ex
+++ b/lib/philomena/bans/id_generator.ex
@@ -1,4 +1,6 @@
-defmodule Philomena.Schema.BanId do
+defmodule Philomena.Bans.IdGenerator do
+ @moduledoc false
+
import Ecto.Changeset
def put_ban_id(%{data: %{generated_ban_id: nil}} = changeset, prefix) do
diff --git a/lib/philomena/bans/subnet.ex b/lib/philomena/bans/subnet.ex
index 1bd4ee00..2eeb424a 100644
--- a/lib/philomena/bans/subnet.ex
+++ b/lib/philomena/bans/subnet.ex
@@ -1,10 +1,9 @@
defmodule Philomena.Bans.Subnet do
use Ecto.Schema
import Ecto.Changeset
+ import Philomena.Bans.IdGenerator
alias Philomena.Users.User
- alias Philomena.Schema.Time
- alias Philomena.Schema.BanId
schema "subnet_bans" do
belongs_to :banning_user, User
@@ -12,27 +11,18 @@ defmodule Philomena.Bans.Subnet do
field :reason, :string
field :note, :string
field :enabled, :boolean, default: true
- field :valid_until, :utc_datetime
+ field :valid_until, PhilomenaQuery.Ecto.RelativeDate
field :specification, EctoNetwork.INET
field :generated_ban_id, :string
- field :until, :string, virtual: true
-
timestamps(inserted_at: :created_at, type: :utc_datetime)
end
@doc false
def changeset(subnet_ban, attrs) do
subnet_ban
- |> cast(attrs, [])
- |> Time.propagate_time(:valid_until, :until)
- end
-
- def save_changeset(subnet_ban, attrs) do
- subnet_ban
- |> cast(attrs, [:reason, :note, :enabled, :specification, :until])
- |> Time.assign_time(:until, :valid_until)
- |> BanId.put_ban_id("S")
+ |> cast(attrs, [:reason, :note, :enabled, :specification, :valid_until])
+ |> put_ban_id("S")
|> validate_required([:reason, :enabled, :specification, :valid_until])
|> check_constraint(:valid_until, name: :subnet_ban_duration_must_be_valid)
|> mask_specification()
diff --git a/lib/philomena/bans/user.ex b/lib/philomena/bans/user.ex
index c2514191..efae6214 100644
--- a/lib/philomena/bans/user.ex
+++ b/lib/philomena/bans/user.ex
@@ -1,11 +1,9 @@
defmodule Philomena.Bans.User do
use Ecto.Schema
import Ecto.Changeset
+ import Philomena.Bans.IdGenerator
alias Philomena.Users.User
- alias Philomena.Repo
- alias Philomena.Schema.Time
- alias Philomena.Schema.BanId
schema "user_bans" do
belongs_to :user, User
@@ -14,48 +12,19 @@ defmodule Philomena.Bans.User do
field :reason, :string
field :note, :string
field :enabled, :boolean, default: true
- field :valid_until, :utc_datetime
+ field :valid_until, PhilomenaQuery.Ecto.RelativeDate
field :generated_ban_id, :string
field :override_ip_ban, :boolean, default: false
- field :username, :string, virtual: true
- field :until, :string, virtual: true
-
timestamps(inserted_at: :created_at, type: :utc_datetime)
end
@doc false
def changeset(user_ban, attrs) do
user_ban
- |> cast(attrs, [])
- |> Time.propagate_time(:valid_until, :until)
- |> populate_username()
- end
-
- def save_changeset(user_ban, attrs) do
- user_ban
- |> cast(attrs, [:reason, :note, :enabled, :override_ip_ban, :username, :until])
- |> Time.assign_time(:until, :valid_until)
- |> populate_user_id()
- |> BanId.put_ban_id("U")
+ |> cast(attrs, [:reason, :note, :enabled, :override_ip_ban, :user_id, :valid_until])
+ |> put_ban_id("U")
|> validate_required([:reason, :enabled, :user_id, :valid_until])
|> check_constraint(:valid_until, name: :user_ban_duration_must_be_valid)
end
-
- defp populate_username(changeset) do
- case maybe_get_by(:id, get_field(changeset, :user_id)) do
- nil -> changeset
- user -> put_change(changeset, :username, user.name)
- end
- end
-
- defp populate_user_id(changeset) do
- case maybe_get_by(:name, get_field(changeset, :username)) do
- nil -> changeset
- %{id: id} -> put_change(changeset, :user_id, id)
- end
- end
-
- defp maybe_get_by(_field, nil), do: nil
- defp maybe_get_by(field, value), do: Repo.get_by(User, [{field, value}])
end
diff --git a/lib/philomena/channels.ex b/lib/philomena/channels.ex
index d1d31bce..efa6d47c 100644
--- a/lib/philomena/channels.ex
+++ b/lib/philomena/channels.ex
@@ -9,6 +9,11 @@ defmodule Philomena.Channels do
alias Philomena.Channels.AutomaticUpdater
alias Philomena.Channels.Channel
alias Philomena.Notifications
+ alias Philomena.Tags
+
+ use Philomena.Subscriptions,
+ on_delete: :clear_channel_notification,
+ id_name: :channel_id
@doc """
Updates all the tracked channels for which an update scheme is known.
@@ -47,6 +52,7 @@ defmodule Philomena.Channels do
"""
def create_channel(attrs \\ %{}) do
%Channel{}
+ |> update_artist_tag(attrs)
|> Channel.changeset(attrs)
|> Repo.insert()
end
@@ -65,10 +71,29 @@ defmodule Philomena.Channels do
"""
def update_channel(%Channel{} = channel, attrs) do
channel
+ |> update_artist_tag(attrs)
|> Channel.changeset(attrs)
|> Repo.update()
end
+ @doc """
+ Adds the artist tag from the `"artist_tag"` tag name attribute.
+
+ ## Examples
+
+ iex> update_artist_tag(%Channel{}, %{"artist_tag" => "artist:nighty"})
+ %Ecto.Changeset{}
+
+ """
+ def update_artist_tag(%Channel{} = channel, attrs) do
+ tag =
+ attrs
+ |> Map.get("artist_tag", "")
+ |> Tags.get_tag_by_name()
+
+ Channel.artist_tag_changeset(channel, tag)
+ end
+
@doc """
Updates a channel's state when it goes live.
@@ -116,68 +141,17 @@ defmodule Philomena.Channels do
Channel.changeset(channel, %{})
end
- alias Philomena.Channels.Subscription
-
@doc """
- Creates a subscription.
+ Removes all channel notifications for a given channel and user.
## Examples
- iex> create_subscription(%{field: value})
- {:ok, %Subscription{}}
-
- iex> create_subscription(%{field: bad_value})
- {:error, %Ecto.Changeset{}}
+ iex> clear_channel_notification(channel, user)
+ :ok
"""
- def create_subscription(_channel, nil), do: {:ok, nil}
-
- def create_subscription(channel, user) do
- %Subscription{channel_id: channel.id, user_id: user.id}
- |> Subscription.changeset(%{})
- |> Repo.insert(on_conflict: :nothing)
- end
-
- @doc """
- Deletes a Subscription.
-
- ## Examples
-
- iex> delete_subscription(subscription)
- {:ok, %Subscription{}}
-
- iex> delete_subscription(subscription)
- {:error, %Ecto.Changeset{}}
-
- """
- def delete_subscription(channel, user) do
- clear_notification(channel, user)
-
- %Subscription{channel_id: channel.id, user_id: user.id}
- |> Repo.delete()
- end
-
- def subscribed?(_channel, nil), do: false
-
- def subscribed?(channel, user) do
- Subscription
- |> where(channel_id: ^channel.id, user_id: ^user.id)
- |> Repo.exists?()
- end
-
- def subscriptions(_channels, nil), do: %{}
-
- def subscriptions(channels, user) do
- channel_ids = Enum.map(channels, & &1.id)
-
- Subscription
- |> where([s], s.channel_id in ^channel_ids and s.user_id == ^user.id)
- |> Repo.all()
- |> Map.new(&{&1.channel_id, true})
- end
-
- def clear_notification(channel, user) do
- Notifications.delete_unread_notification("Channel", channel.id, user)
- Notifications.delete_unread_notification("LivestreamChannel", channel.id, user)
+ def clear_channel_notification(%Channel{} = channel, user) do
+ Notifications.clear_channel_live_notification(channel, user)
+ :ok
end
end
diff --git a/lib/philomena/channels/channel.ex b/lib/philomena/channels/channel.ex
index 3af5a8b1..7f70351a 100644
--- a/lib/philomena/channels/channel.ex
+++ b/lib/philomena/channels/channel.ex
@@ -3,7 +3,6 @@ defmodule Philomena.Channels.Channel do
import Ecto.Changeset
alias Philomena.Tags.Tag
- alias Philomena.Repo
schema "channels" do
belongs_to :associated_artist_tag, Tag
@@ -13,22 +12,12 @@ defmodule Philomena.Channels.Channel do
field :short_name, :string
field :title, :string, default: ""
- field :tags, :string
field :viewers, :integer, default: 0
field :nsfw, :boolean, default: false
field :is_live, :boolean, default: false
field :last_fetched_at, :utc_datetime
field :next_check_at, :utc_datetime
field :last_live_at, :utc_datetime
-
- field :viewer_minutes_today, :integer, default: 0
- field :viewer_minutes_thisweek, :integer, default: 0
- field :viewer_minutes_thismonth, :integer, default: 0
- field :total_viewer_minutes, :integer, default: 0
-
- field :banner_image, :string
- field :channel_image, :string
- field :remote_stream_id, :integer
field :thumbnail_url, :string, default: ""
timestamps(inserted_at: :created_at, type: :utc_datetime)
@@ -36,19 +25,13 @@ defmodule Philomena.Channels.Channel do
@doc false
def changeset(channel, attrs) do
- tag_id =
- case Repo.get_by(Tag, name: attrs["artist_tag"] || "") do
- %{id: id} -> id
- _ -> nil
- end
-
channel
|> cast(attrs, [:type, :short_name])
|> validate_required([:type, :short_name])
|> validate_inclusion(:type, ["PicartoChannel", "PiczelChannel"])
- |> put_change(:associated_artist_tag_id, tag_id)
end
+ @doc false
def update_changeset(channel, attrs) do
cast(channel, attrs, [
:title,
@@ -60,4 +43,11 @@ defmodule Philomena.Channels.Channel do
:last_live_at
])
end
+
+ @doc false
+ def artist_tag_changeset(channel, tag) do
+ tag_id = Map.get(tag || %{}, :id)
+
+ change(channel, associated_artist_tag_id: tag_id)
+ end
end
diff --git a/lib/philomena/comments.ex b/lib/philomena/comments.ex
index 1d606ff0..e63a288a 100644
--- a/lib/philomena/comments.ex
+++ b/lib/philomena/comments.ex
@@ -8,7 +8,6 @@ defmodule Philomena.Comments do
alias Philomena.Repo
alias PhilomenaQuery.Search
- alias Philomena.Reports.Report
alias Philomena.UserStatistics
alias Philomena.Comments.Comment
alias Philomena.Comments.SearchIndex, as: CommentIndex
@@ -16,10 +15,8 @@ defmodule Philomena.Comments do
alias Philomena.Images.Image
alias Philomena.Images
alias Philomena.Notifications
- alias Philomena.NotificationWorker
alias Philomena.Versions
alias Philomena.Reports
- alias Philomena.Users.User
@doc """
Gets a single comment.
@@ -58,52 +55,20 @@ defmodule Philomena.Comments do
Image
|> where(id: ^image.id)
+ image_lock_query =
+ lock(image_query, "FOR UPDATE")
+
Multi.new()
+ |> Multi.one(:image, image_lock_query)
|> Multi.insert(:comment, comment)
- |> Multi.update_all(:image, image_query, inc: [comments_count: 1])
- |> maybe_create_subscription_on_reply(image, attribution[:user])
+ |> Multi.update_all(:update_image, image_query, inc: [comments_count: 1])
+ |> Multi.run(:notification, ¬ify_comment/2)
+ |> Images.maybe_subscribe_on(:image, attribution[:user], :watch_on_reply)
|> Repo.transaction()
end
- defp maybe_create_subscription_on_reply(multi, image, %User{watch_on_reply: true} = user) do
- multi
- |> Multi.run(:subscribe, fn _repo, _changes ->
- Images.create_subscription(image, user)
- end)
- end
-
- defp maybe_create_subscription_on_reply(multi, _image, _user) do
- multi
- end
-
- def notify_comment(comment) do
- Exq.enqueue(Exq, "notifications", NotificationWorker, ["Comments", comment.id])
- end
-
- def perform_notify(comment_id) do
- comment = get_comment!(comment_id)
-
- image =
- comment
- |> Repo.preload(:image)
- |> Map.fetch!(:image)
-
- subscriptions =
- image
- |> Repo.preload(:subscriptions)
- |> Map.fetch!(:subscriptions)
-
- Notifications.notify(
- comment,
- subscriptions,
- %{
- actor_id: image.id,
- actor_type: "Image",
- actor_child_id: comment.id,
- actor_child_type: "Comment",
- action: "commented on"
- }
- )
+ defp notify_comment(_repo, %{image: image, comment: comment}) do
+ Notifications.create_image_comment_notification(comment.user, image, comment)
end
@doc """
@@ -119,7 +84,7 @@ defmodule Philomena.Comments do
"""
def update_comment(%Comment{} = comment, editor, attrs) do
- now = DateTime.utc_now() |> DateTime.truncate(:second)
+ now = DateTime.utc_now(:second)
current_body = comment.body
current_reason = comment.edit_reason
@@ -153,17 +118,12 @@ defmodule Philomena.Comments do
end
def hide_comment(%Comment{} = comment, attrs, user) do
- reports =
- Report
- |> where(reportable_type: "Comment", reportable_id: ^comment.id)
- |> select([r], r.id)
- |> update(set: [open: false, state: "closed", admin_id: ^user.id])
-
+ report_query = Reports.close_report_query({"Comment", comment.id}, user)
comment = Comment.hide_changeset(comment, attrs, user)
Multi.new()
|> Multi.update(:comment, comment)
- |> Multi.update_all(:reports, reports, [])
+ |> Multi.update_all(:reports, report_query, [])
|> Repo.transaction()
|> case do
{:ok, %{comment: comment, reports: {_count, reports}}} ->
@@ -199,21 +159,15 @@ defmodule Philomena.Comments do
end
def approve_comment(%Comment{} = comment, user) do
- reports =
- Report
- |> where(reportable_type: "Comment", reportable_id: ^comment.id)
- |> select([r], r.id)
- |> update(set: [open: false, state: "closed", admin_id: ^user.id])
-
+ report_query = Reports.close_report_query({"Comment", comment.id}, user)
comment = Comment.approve_changeset(comment)
Multi.new()
|> Multi.update(:comment, comment)
- |> Multi.update_all(:reports, reports, [])
+ |> Multi.update_all(:reports, report_query, [])
|> Repo.transaction()
|> case do
{:ok, %{comment: comment, reports: {_count, reports}}} ->
- notify_comment(comment)
UserStatistics.inc_stat(comment.user, :comments_posted)
Reports.reindex_reports(reports)
reindex_comment(comment)
@@ -229,8 +183,7 @@ defmodule Philomena.Comments do
def report_non_approved(comment) do
Reports.create_system_report(
- comment.id,
- "Comment",
+ {"Comment", comment.id},
"Approval",
"Comment contains externally-embedded images and has been flagged for review."
)
diff --git a/lib/philomena/comments/comment.ex b/lib/philomena/comments/comment.ex
index 9571b450..e54a415b 100644
--- a/lib/philomena/comments/comment.ex
+++ b/lib/philomena/comments/comment.ex
@@ -14,15 +14,12 @@ defmodule Philomena.Comments.Comment do
field :body, :string
field :ip, EctoNetwork.INET
field :fingerprint, :string
- field :user_agent, :string, default: ""
- field :referrer, :string, default: ""
field :anonymous, :boolean, default: false
field :hidden_from_users, :boolean, default: false
field :edit_reason, :string
field :edited_at, :utc_datetime
field :deletion_reason, :string, default: ""
field :destroyed_content, :boolean, default: false
- field :name_at_post_time, :string
field :approved, :boolean
timestamps(inserted_at: :created_at, type: :utc_datetime)
@@ -35,7 +32,6 @@ defmodule Philomena.Comments.Comment do
|> validate_required([:body])
|> validate_length(:body, min: 1, max: 300_000, count: :bytes)
|> change(attribution)
- |> put_name_at_post_time(attribution[:user])
|> Approval.maybe_put_approval(attribution[:user])
|> Approval.maybe_strip_images(attribution[:user])
end
@@ -74,7 +70,4 @@ defmodule Philomena.Comments.Comment do
change(comment)
|> put_change(:approved, true)
end
-
- defp put_name_at_post_time(changeset, nil), do: changeset
- defp put_name_at_post_time(changeset, user), do: change(changeset, name_at_post_time: user.name)
end
diff --git a/lib/philomena/comments/query.ex b/lib/philomena/comments/query.ex
index 9e9c8986..49ec6d68 100644
--- a/lib/philomena/comments/query.ex
+++ b/lib/philomena/comments/query.ex
@@ -92,8 +92,8 @@ defmodule Philomena.Comments.Query do
|> Parser.parse(query_string, context)
end
- def compile(user, query_string) do
- query_string = query_string || ""
+ def compile(query_string, opts \\ []) do
+ user = Keyword.get(opts, :user)
case user do
nil ->
diff --git a/lib/philomena/conversations.ex b/lib/philomena/conversations.ex
index 597b64f7..aacf6b94 100644
--- a/lib/philomena/conversations.ex
+++ b/lib/philomena/conversations.ex
@@ -6,76 +6,112 @@ defmodule Philomena.Conversations do
import Ecto.Query, warn: false
alias Ecto.Multi
alias Philomena.Repo
- alias Philomena.Reports
- alias Philomena.Reports.Report
alias Philomena.Conversations.Conversation
+ alias Philomena.Conversations.Message
+ alias Philomena.Reports
+ alias Philomena.Users
@doc """
- Gets a single conversation.
+ Returns the number of unread conversations for the given user.
- Raises `Ecto.NoResultsError` if the Conversation does not exist.
+ Conversations hidden by the given user are not counted.
## Examples
- iex> get_conversation!(123)
- %Conversation{}
+ iex> count_unread_conversations(user1)
+ 0
- iex> get_conversation!(456)
- ** (Ecto.NoResultsError)
+ iex> count_unread_conversations(user2)
+ 7
"""
- def get_conversation!(id), do: Repo.get!(Conversation, id)
+ def count_unread_conversations(user) do
+ Conversation
+ |> where(
+ [c],
+ ((c.to_id == ^user.id and c.to_read == false) or
+ (c.from_id == ^user.id and c.from_read == false)) and
+ not ((c.to_id == ^user.id and c.to_hidden == true) or
+ (c.from_id == ^user.id and c.from_hidden == true))
+ )
+ |> Repo.aggregate(:count)
+ end
+
+ @doc """
+ Returns a `m:Scrivener.Page` of conversations between the partner and the user.
+
+ ## Examples
+
+ iex> list_conversations_with("123", %User{}, page_size: 10)
+ %Scrivener.Page{}
+
+ """
+ def list_conversations_with(partner_id, user, pagination) do
+ query =
+ from c in Conversation,
+ where:
+ (c.from_id == ^partner_id and c.to_id == ^user.id) or
+ (c.to_id == ^partner_id and c.from_id == ^user.id)
+
+ list_conversations(query, user, pagination)
+ end
+
+ @doc """
+ Returns a `m:Scrivener.Page` of conversations sent by or received from the user.
+
+ ## Examples
+
+ iex> list_conversations_with("123", %User{}, page_size: 10)
+ %Scrivener.Page{}
+
+ """
+ def list_conversations(queryable \\ Conversation, user, pagination) do
+ query =
+ from c in queryable,
+ as: :conversations,
+ where:
+ (c.from_id == ^user.id and not c.from_hidden) or
+ (c.to_id == ^user.id and not c.to_hidden),
+ inner_lateral_join:
+ cnt in subquery(
+ from m in Message,
+ where: m.conversation_id == parent_as(:conversations).id,
+ select: %{count: count()}
+ ),
+ on: true,
+ order_by: [desc: :last_message_at],
+ preload: [:to, :from],
+ select: %{c | message_count: cnt.count}
+
+ Repo.paginate(query, pagination)
+ end
@doc """
Creates a conversation.
## Examples
- iex> create_conversation(%{field: value})
+ iex> create_conversation(from, to, %{field: value})
{:ok, %Conversation{}}
- iex> create_conversation(%{field: bad_value})
+ iex> create_conversation(from, to, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_conversation(from, attrs \\ %{}) do
+ to = Users.get_user_by_name(attrs["recipient"])
+
%Conversation{}
- |> Conversation.creation_changeset(from, attrs)
+ |> Conversation.creation_changeset(from, to, attrs)
|> Repo.insert()
- end
+ |> case do
+ {:ok, conversation} ->
+ report_non_approved_message(hd(conversation.messages))
+ {:ok, conversation}
- @doc """
- Updates a conversation.
-
- ## Examples
-
- iex> update_conversation(conversation, %{field: new_value})
- {:ok, %Conversation{}}
-
- iex> update_conversation(conversation, %{field: bad_value})
- {:error, %Ecto.Changeset{}}
-
- """
- def update_conversation(%Conversation{} = conversation, attrs) do
- conversation
- |> Conversation.changeset(attrs)
- |> Repo.update()
- end
-
- @doc """
- Deletes a Conversation.
-
- ## Examples
-
- iex> delete_conversation(conversation)
- {:ok, %Conversation{}}
-
- iex> delete_conversation(conversation)
- {:error, %Ecto.Changeset{}}
-
- """
- def delete_conversation(%Conversation{} = conversation) do
- Repo.delete(conversation)
+ error ->
+ error
+ end
end
@doc """
@@ -91,201 +127,221 @@ defmodule Philomena.Conversations do
Conversation.changeset(conversation, %{})
end
- def count_unread_conversations(user) do
- Conversation
- |> where(
- [c],
- ((c.to_id == ^user.id and c.to_read == false) or
- (c.from_id == ^user.id and c.from_read == false)) and
- not ((c.to_id == ^user.id and c.to_hidden == true) or
- (c.from_id == ^user.id and c.from_hidden == true))
- )
- |> Repo.aggregate(:count, :id)
- end
-
- def mark_conversation_read(conversation, user, read \\ true)
-
- def mark_conversation_read(
- %Conversation{to_id: user_id, from_id: user_id} = conversation,
- %{id: user_id},
- read
- ) do
- conversation
- |> Conversation.read_changeset(%{to_read: read, from_read: read})
- |> Repo.update()
- end
-
- def mark_conversation_read(%Conversation{to_id: user_id} = conversation, %{id: user_id}, read) do
- conversation
- |> Conversation.read_changeset(%{to_read: read})
- |> Repo.update()
- end
-
- def mark_conversation_read(%Conversation{from_id: user_id} = conversation, %{id: user_id}, read) do
- conversation
- |> Conversation.read_changeset(%{from_read: read})
- |> Repo.update()
- end
-
- def mark_conversation_read(_conversation, _user, _read), do: {:ok, nil}
-
- def mark_conversation_hidden(conversation, user, hidden \\ true)
-
- def mark_conversation_hidden(
- %Conversation{to_id: user_id} = conversation,
- %{id: user_id},
- hidden
- ) do
- conversation
- |> Conversation.hidden_changeset(%{to_hidden: hidden})
- |> Repo.update()
- end
-
- def mark_conversation_hidden(
- %Conversation{from_id: user_id} = conversation,
- %{id: user_id},
- hidden
- ) do
- conversation
- |> Conversation.hidden_changeset(%{from_hidden: hidden})
- |> Repo.update()
- end
-
- def mark_conversation_hidden(_conversation, _user, _read), do: {:ok, nil}
-
- alias Philomena.Conversations.Message
-
@doc """
- Gets a single message.
-
- Raises `Ecto.NoResultsError` if the Message does not exist.
+ Marks a conversation as read or unread from the perspective of the given user.
## Examples
- iex> get_message!(123)
- %Message{}
+ iex> mark_conversation_read(conversation, user, true)
+ {:ok, %Conversation{}}
- iex> get_message!(456)
- ** (Ecto.NoResultsError)
+ iex> mark_conversation_read(conversation, user, false)
+ {:ok, %Conversation{}}
+
+ iex> mark_conversation_read(conversation, %User{}, true)
+ {:error, %Ecto.Changeset{}}
"""
- def get_message!(id), do: Repo.get!(Message, id)
+ def mark_conversation_read(%Conversation{} = conversation, user, read \\ true) do
+ changes =
+ %{}
+ |> put_conditional(:to_read, read, conversation.to_id == user.id)
+ |> put_conditional(:from_read, read, conversation.from_id == user.id)
+
+ conversation
+ |> Conversation.read_changeset(changes)
+ |> Repo.update()
+ end
@doc """
- Creates a message.
+ Marks a conversation as hidden or visible from the perspective of the given user.
+
+ Hidden conversations are not shown in the list of conversations for the user, and
+ are not counted when retrieving the number of unread conversations.
## Examples
- iex> create_message(%{field: value})
+ iex> mark_conversation_hidden(conversation, user, true)
+ {:ok, %Conversation{}}
+
+ iex> mark_conversation_hidden(conversation, user, false)
+ {:ok, %Conversation{}}
+
+ iex> mark_conversation_hidden(conversation, %User{}, true)
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def mark_conversation_hidden(%Conversation{} = conversation, user, hidden \\ true) do
+ changes =
+ %{}
+ |> put_conditional(:to_hidden, hidden, conversation.to_id == user.id)
+ |> put_conditional(:from_hidden, hidden, conversation.from_id == user.id)
+
+ conversation
+ |> Conversation.hidden_changeset(changes)
+ |> Repo.update()
+ end
+
+ defp put_conditional(map, key, value, condition) do
+ if condition do
+ Map.put(map, key, value)
+ else
+ map
+ end
+ end
+
+ @doc """
+ Returns the number of messages in the given conversation.
+
+ ## Example
+
+ iex> count_messages(%Conversation{})
+ 3
+
+ """
+ def count_messages(conversation) do
+ Message
+ |> where(conversation_id: ^conversation.id)
+ |> Repo.aggregate(:count)
+ end
+
+ @doc """
+ Returns a `m:Scrivener.Page` of 2-tuples of messages and rendered output
+ within a conversation.
+
+ Messages are ordered by user message preference (`messages_newest_first`).
+
+ When coerced to a list and rendered as Markdown, the result may look like:
+
+ [
+ {%Message{body: "hello *world*"}, "hello world"}
+ ]
+
+ ## Example
+
+ iex> list_messages(%Conversation{}, %User{}, & &1.body, page_size: 10)
+ %Scrivener.Page{}
+
+ """
+ def list_messages(conversation, user, collection_renderer, pagination) do
+ direction =
+ if user.messages_newest_first do
+ :desc
+ else
+ :asc
+ end
+
+ query =
+ from m in Message,
+ where: m.conversation_id == ^conversation.id,
+ order_by: [{^direction, :created_at}],
+ preload: :from
+
+ messages = Repo.paginate(query, pagination)
+ rendered = collection_renderer.(messages)
+
+ put_in(messages.entries, Enum.zip(messages.entries, rendered))
+ end
+
+ @doc """
+ Creates a message within a conversation.
+
+ ## Examples
+
+ iex> create_message(%Conversation{}, %User{}, %{field: value})
{:ok, %Message{}}
- iex> create_message(%{field: bad_value})
+ iex> create_message(%Conversation{}, %User{}, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_message(conversation, user, attrs \\ %{}) do
- message =
- Ecto.build_assoc(conversation, :messages)
+ message_changeset =
+ conversation
+ |> Ecto.build_assoc(:messages)
|> Message.creation_changeset(attrs, user)
- show_as_read =
- case message do
- %{changes: %{approved: true}} -> false
- _ -> true
- end
-
- conversation_query =
- Conversation
- |> where(id: ^conversation.id)
-
- now = DateTime.utc_now()
+ conversation_changeset =
+ Conversation.new_message_changeset(conversation)
Multi.new()
- |> Multi.insert(:message, message)
- |> Multi.update_all(:conversation, conversation_query,
- set: [from_read: show_as_read, to_read: show_as_read, last_message_at: now]
- )
- |> Repo.transaction()
- end
-
- def approve_conversation_message(message, user) do
- reports_query =
- Report
- |> where(reportable_type: "Conversation", reportable_id: ^message.conversation_id)
- |> select([r], r.id)
- |> update(set: [open: false, state: "closed", admin_id: ^user.id])
-
- message_query =
- message
- |> Message.approve_changeset()
-
- conversation_query =
- Conversation
- |> where(id: ^message.conversation_id)
-
- Multi.new()
- |> Multi.update(:message, message_query)
- |> Multi.update_all(:conversation, conversation_query, set: [to_read: false])
- |> Multi.update_all(:reports, reports_query, [])
+ |> Multi.insert(:message, message_changeset)
+ |> Multi.update(:conversation, conversation_changeset)
|> Repo.transaction()
|> case do
- {:ok, %{reports: {_count, reports}} = result} ->
- Reports.reindex_reports(reports)
+ {:ok, %{message: message}} ->
+ report_non_approved_message(message)
+ {:ok, message}
- {:ok, result}
-
- error ->
- error
+ _error ->
+ {:error, message_changeset}
end
end
- def report_non_approved(id) do
- Reports.create_system_report(
- id,
- "Conversation",
- "Approval",
- "PM contains externally-embedded images and has been flagged for review."
- )
- end
-
- def set_as_read(conversation) do
- conversation
- |> Conversation.to_read_changeset()
- |> Repo.update()
- end
-
@doc """
- Updates a message.
+ Approves a previously-posted message which was not approved at post time.
## Examples
- iex> update_message(message, %{field: new_value})
+ iex> approve_message(%Message{}, %User{})
{:ok, %Message{}}
- iex> update_message(message, %{field: bad_value})
+ iex> approve_message(%Message{}, %User{})
{:error, %Ecto.Changeset{}}
"""
- def update_message(%Message{} = message, attrs) do
- message
- |> Message.changeset(attrs)
- |> Repo.update()
+ def approve_message(message, approving_user) do
+ message_changeset = Message.approve_changeset(message)
+
+ conversation_update_query =
+ from c in Conversation,
+ where: c.id == ^message.conversation_id,
+ update: [set: [from_read: false, to_read: false]]
+
+ reports_query =
+ Reports.close_report_query({"Conversation", message.conversation_id}, approving_user)
+
+ Multi.new()
+ |> Multi.update(:message, message_changeset)
+ |> Multi.update_all(:conversation, conversation_update_query, [])
+ |> Multi.update_all(:reports, reports_query, [])
+ |> Repo.transaction()
+ |> case do
+ {:ok, %{reports: {_count, reports}, message: message}} ->
+ Reports.reindex_reports(reports)
+
+ message
+
+ _error ->
+ {:error, message_changeset}
+ end
end
@doc """
- Deletes a Message.
+ Generates a system report for an unapproved message.
+
+ This is called by `create_conversation/2` and `create_message/3`, so it normally does not
+ need to be called explicitly.
## Examples
- iex> delete_message(message)
- {:ok, %Message{}}
+ iex> report_non_approved_message(%Message{approved: false})
+ {:ok, %Report{}}
- iex> delete_message(message)
- {:error, %Ecto.Changeset{}}
+ iex> report_non_approved_message(%Message{approved: true})
+ {:ok, nil}
"""
- def delete_message(%Message{} = message) do
- Repo.delete(message)
+ def report_non_approved_message(message) do
+ if message.approved do
+ {:ok, nil}
+ else
+ Reports.create_system_report(
+ {"Conversation", message.conversation_id},
+ "Approval",
+ "PM contains externally-embedded images and has been flagged for review."
+ )
+ end
end
@doc """
diff --git a/lib/philomena/conversations/conversation.ex b/lib/philomena/conversations/conversation.ex
index ac188f1d..b0122eb2 100644
--- a/lib/philomena/conversations/conversation.ex
+++ b/lib/philomena/conversations/conversation.ex
@@ -4,7 +4,6 @@ defmodule Philomena.Conversations.Conversation do
alias Philomena.Users.User
alias Philomena.Conversations.Message
- alias Philomena.Repo
@derive {Phoenix.Param, key: :slug}
@@ -20,6 +19,8 @@ defmodule Philomena.Conversations.Conversation do
field :from_hidden, :boolean, default: false
field :slug, :string
field :last_message_at, :utc_datetime
+
+ field :message_count, :integer, virtual: true
field :recipient, :string, virtual: true
timestamps(inserted_at: :created_at, type: :utc_datetime)
@@ -32,51 +33,39 @@ defmodule Philomena.Conversations.Conversation do
|> validate_required([])
end
+ @doc false
def read_changeset(conversation, attrs) do
- conversation
- |> cast(attrs, [:from_read, :to_read])
- end
-
- def to_read_changeset(conversation) do
- change(conversation)
- |> put_change(:to_read, true)
- end
-
- def hidden_changeset(conversation, attrs) do
- conversation
- |> cast(attrs, [:from_hidden, :to_hidden])
+ cast(conversation, attrs, [:from_read, :to_read])
end
@doc false
- def creation_changeset(conversation, from, attrs) do
- conversation
- |> cast(attrs, [:title, :recipient])
- |> validate_required([:title, :recipient])
- |> validate_length(:title, max: 300, count: :bytes)
- |> put_assoc(:from, from)
- |> put_recipient()
- |> set_slug()
- |> set_last_message()
- |> cast_assoc(:messages, with: &Message.creation_changeset(&1, &2, from))
- |> validate_length(:messages, is: 1)
+ def hidden_changeset(conversation, attrs) do
+ cast(conversation, attrs, [:from_hidden, :to_hidden])
end
- defp set_slug(changeset) do
- changeset
- |> change(slug: Ecto.UUID.generate())
+ @doc false
+ def creation_changeset(conversation, from, to, attrs) do
+ conversation
+ |> cast(attrs, [:title])
+ |> put_assoc(:from, from)
+ |> put_assoc(:to, to)
+ |> put_change(:slug, Ecto.UUID.generate())
+ |> cast_assoc(:messages, with: &Message.creation_changeset(&1, &2, from))
+ |> set_last_message()
+ |> validate_length(:messages, is: 1)
+ |> validate_length(:title, max: 300, count: :bytes)
+ |> validate_required([:title, :from, :to])
+ end
+
+ @doc false
+ def new_message_changeset(conversation) do
+ conversation
+ |> change(from_read: false)
+ |> change(to_read: false)
+ |> set_last_message()
end
defp set_last_message(changeset) do
- changeset
- |> change(last_message_at: DateTime.utc_now() |> DateTime.truncate(:second))
- end
-
- defp put_recipient(changeset) do
- recipient = changeset |> get_field(:recipient)
- user = Repo.get_by(User, name: recipient)
-
- changeset
- |> put_change(:to, user)
- |> validate_required(:to)
+ change(changeset, last_message_at: DateTime.utc_now(:second))
end
end
diff --git a/lib/philomena/conversations/message.ex b/lib/philomena/conversations/message.ex
index a9e6fefd..4dced3af 100644
--- a/lib/philomena/conversations/message.ex
+++ b/lib/philomena/conversations/message.ex
@@ -33,6 +33,7 @@ defmodule Philomena.Conversations.Message do
|> Approval.maybe_put_approval(user)
end
+ @doc false
def approve_changeset(message) do
change(message, approved: true)
end
diff --git a/lib/philomena/duplicate_reports.ex b/lib/philomena/duplicate_reports.ex
index 3e07151e..a9cad67b 100644
--- a/lib/philomena/duplicate_reports.ex
+++ b/lib/philomena/duplicate_reports.ex
@@ -3,11 +3,15 @@ defmodule Philomena.DuplicateReports do
The DuplicateReports context.
"""
+ import Philomena.DuplicateReports.Power
import Ecto.Query, warn: false
+
alias Ecto.Multi
alias Philomena.Repo
alias Philomena.DuplicateReports.DuplicateReport
+ alias Philomena.DuplicateReports.SearchQuery
+ alias Philomena.DuplicateReports.Uploader
alias Philomena.ImageIntensities.ImageIntensity
alias Philomena.Images.Image
alias Philomena.Images
@@ -15,7 +19,8 @@ defmodule Philomena.DuplicateReports do
def generate_reports(source) do
source = Repo.preload(source, :intensity)
- duplicates_of(source.intensity, source.image_aspect_ratio, 0.2, 0.05)
+ {source.intensity, source.image_aspect_ratio}
+ |> find_duplicates(dist: 0.2)
|> where([i, _it], i.id != ^source.id)
|> Repo.all()
|> Enum.map(fn target ->
@@ -25,7 +30,11 @@ defmodule Philomena.DuplicateReports do
end)
end
- def duplicates_of(intensities, aspect_ratio, dist \\ 0.25, aspect_dist \\ 0.05) do
+ def find_duplicates({intensities, aspect_ratio}, opts \\ []) do
+ aspect_dist = Keyword.get(opts, :aspect_dist, 0.05)
+ limit = Keyword.get(opts, :limit, 10)
+ dist = Keyword.get(opts, :dist, 0.25)
+
# for each color channel
dist = dist * 3
@@ -39,7 +48,72 @@ defmodule Philomena.DuplicateReports do
where:
i.image_aspect_ratio >= ^(aspect_ratio - aspect_dist) and
i.image_aspect_ratio <= ^(aspect_ratio + aspect_dist),
- limit: 10
+ order_by: [
+ asc:
+ power(it.nw - ^intensities.nw, 2) +
+ power(it.ne - ^intensities.ne, 2) +
+ power(it.sw - ^intensities.sw, 2) +
+ power(it.se - ^intensities.se, 2) +
+ power(i.image_aspect_ratio - ^aspect_ratio, 2)
+ ],
+ limit: ^limit
+ end
+
+ @doc """
+ Executes the reverse image search query from parameters.
+
+ ## Examples
+
+ iex> execute_search_query(%{"image" => ..., "distance" => "0.25"})
+ {:ok, [%Image{...}, ....]}
+
+ iex> execute_search_query(%{"image" => ..., "distance" => "asdf"})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def execute_search_query(attrs \\ %{}) do
+ %SearchQuery{}
+ |> SearchQuery.changeset(attrs)
+ |> Uploader.analyze_upload(attrs)
+ |> Ecto.Changeset.apply_action(:create)
+ |> case do
+ {:ok, search_query} ->
+ intensities = generate_intensities(search_query)
+ aspect = search_query.image_aspect_ratio
+ limit = search_query.limit
+ dist = search_query.distance
+
+ images =
+ {intensities, aspect}
+ |> find_duplicates(dist: dist, aspect_dist: dist, limit: limit)
+ |> preload([:user, :intensity, [:sources, tags: :aliases]])
+ |> Repo.paginate(page_size: 50)
+
+ {:ok, images}
+
+ error ->
+ error
+ end
+ end
+
+ defp generate_intensities(search_query) do
+ analysis = SearchQuery.to_analysis(search_query)
+ file = search_query.uploaded_image
+
+ PhilomenaMedia.Processors.intensities(analysis, file)
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for tracking search query changes.
+
+ ## Examples
+
+ iex> change_search_query(search_query)
+ %Ecto.Changeset{source: %SearchQuery{}}
+
+ """
+ def change_search_query(%SearchQuery{} = search_query) do
+ SearchQuery.changeset(search_query)
end
@doc """
diff --git a/lib/philomena/duplicate_reports/power.ex b/lib/philomena/duplicate_reports/power.ex
new file mode 100644
index 00000000..32f1bc1c
--- /dev/null
+++ b/lib/philomena/duplicate_reports/power.ex
@@ -0,0 +1,9 @@
+defmodule Philomena.DuplicateReports.Power do
+ @moduledoc false
+
+ defmacro power(left, right) do
+ quote do
+ fragment("power(?, ?)", unquote(left), unquote(right))
+ end
+ end
+end
diff --git a/lib/philomena/duplicate_reports/search_query.ex b/lib/philomena/duplicate_reports/search_query.ex
new file mode 100644
index 00000000..23525c72
--- /dev/null
+++ b/lib/philomena/duplicate_reports/search_query.ex
@@ -0,0 +1,69 @@
+defmodule Philomena.DuplicateReports.SearchQuery do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ embedded_schema do
+ field :distance, :float, default: 0.25
+ field :limit, :integer, default: 10
+
+ field :image_width, :integer
+ field :image_height, :integer
+ field :image_format, :string
+ field :image_duration, :float
+ field :image_mime_type, :string
+ field :image_is_animated, :boolean
+ field :image_aspect_ratio, :float
+ field :uploaded_image, :string, virtual: true
+ end
+
+ @doc false
+ def changeset(search_query, attrs \\ %{}) do
+ search_query
+ |> cast(attrs, [:distance, :limit])
+ |> validate_number(:distance, greater_than_or_equal_to: 0, less_than_or_equal_to: 1)
+ |> validate_number(:limit, greater_than_or_equal_to: 1, less_than_or_equal_to: 50)
+ end
+
+ @doc false
+ def image_changeset(search_query, attrs \\ %{}) do
+ search_query
+ |> cast(attrs, [
+ :image_width,
+ :image_height,
+ :image_format,
+ :image_duration,
+ :image_mime_type,
+ :image_is_animated,
+ :image_aspect_ratio,
+ :uploaded_image
+ ])
+ |> validate_required([
+ :image_width,
+ :image_height,
+ :image_format,
+ :image_duration,
+ :image_mime_type,
+ :image_is_animated,
+ :image_aspect_ratio,
+ :uploaded_image
+ ])
+ |> validate_number(:image_width, greater_than: 0)
+ |> validate_number(:image_height, greater_than: 0)
+ |> validate_inclusion(
+ :image_mime_type,
+ ~W(image/gif image/jpeg image/png image/svg+xml video/webm),
+ message: "(#{attrs["image_mime_type"]}) is invalid"
+ )
+ end
+
+ @doc false
+ def to_analysis(search_query) do
+ %PhilomenaMedia.Analyzers.Result{
+ animated?: search_query.image_is_animated,
+ dimensions: {search_query.image_width, search_query.image_height},
+ duration: search_query.image_duration,
+ extension: search_query.image_format,
+ mime_type: search_query.image_mime_type
+ }
+ end
+end
diff --git a/lib/philomena/duplicate_reports/uploader.ex b/lib/philomena/duplicate_reports/uploader.ex
new file mode 100644
index 00000000..41fc4998
--- /dev/null
+++ b/lib/philomena/duplicate_reports/uploader.ex
@@ -0,0 +1,17 @@
+defmodule Philomena.DuplicateReports.Uploader do
+ @moduledoc """
+ Upload and processing callback logic for SearchQuery images.
+ """
+
+ alias Philomena.DuplicateReports.SearchQuery
+ alias PhilomenaMedia.Uploader
+
+ def analyze_upload(search_query, params) do
+ Uploader.analyze_upload(
+ search_query,
+ "image",
+ params["image"],
+ &SearchQuery.image_changeset/2
+ )
+ end
+end
diff --git a/lib/philomena/filters/filter.ex b/lib/philomena/filters/filter.ex
index ef1222d7..29d542f7 100644
--- a/lib/philomena/filters/filter.ex
+++ b/lib/philomena/filters/filter.ex
@@ -1,9 +1,10 @@
defmodule Philomena.Filters.Filter do
use Ecto.Schema
import Ecto.Changeset
+ import PhilomenaQuery.Ecto.QueryValidator
alias Philomena.Schema.TagList
- alias Philomena.Schema.Search
+ alias Philomena.Images.Query
alias Philomena.Users.User
alias Philomena.Repo
@@ -48,8 +49,8 @@ defmodule Philomena.Filters.Filter do
|> validate_required([:name])
|> validate_my_downvotes(:spoilered_complex_str)
|> validate_my_downvotes(:hidden_complex_str)
- |> Search.validate_search(:spoilered_complex_str, user)
- |> Search.validate_search(:hidden_complex_str, user)
+ |> validate_query(:spoilered_complex_str, &Query.compile(&1, user: user))
+ |> validate_query(:hidden_complex_str, &Query.compile(&1, user: user))
|> unsafe_validate_unique([:user_id, :name], Repo)
end
diff --git a/lib/philomena/filters/query.ex b/lib/philomena/filters/query.ex
index 3b6bb3ef..3460a459 100644
--- a/lib/philomena/filters/query.ex
+++ b/lib/philomena/filters/query.ex
@@ -33,8 +33,8 @@ defmodule Philomena.Filters.Query do
|> Parser.parse(query_string, context)
end
- def compile(user, query_string) do
- query_string = query_string || ""
+ def compile(query_string, opts \\ []) do
+ user = Keyword.get(opts, :user)
case user do
nil ->
diff --git a/lib/philomena/forums.ex b/lib/philomena/forums.ex
index f77df915..7cec4205 100644
--- a/lib/philomena/forums.ex
+++ b/lib/philomena/forums.ex
@@ -7,8 +7,9 @@ defmodule Philomena.Forums do
alias Philomena.Repo
alias Philomena.Forums.Forum
- alias Philomena.Forums.Subscription
- alias Philomena.Notifications
+
+ use Philomena.Subscriptions,
+ id_name: :forum_id
@doc """
Returns the list of forums.
@@ -103,45 +104,4 @@ defmodule Philomena.Forums do
def change_forum(%Forum{} = forum) do
Forum.changeset(forum, %{})
end
-
- def subscribed?(_forum, nil), do: false
-
- def subscribed?(forum, user) do
- Subscription
- |> where(forum_id: ^forum.id, user_id: ^user.id)
- |> Repo.exists?()
- end
-
- def create_subscription(_forum, nil), do: {:ok, nil}
-
- def create_subscription(forum, user) do
- %Subscription{forum_id: forum.id, user_id: user.id}
- |> Subscription.changeset(%{})
- |> Repo.insert(on_conflict: :nothing)
- end
-
- @doc """
- Deletes a Subscription.
-
- ## Examples
-
- iex> delete_subscription(subscription)
- {:ok, %Subscription{}}
-
- iex> delete_subscription(subscription)
- {:error, %Ecto.Changeset{}}
-
- """
- def delete_subscription(forum, user) do
- clear_notification(forum, user)
-
- %Subscription{forum_id: forum.id, user_id: user.id}
- |> Repo.delete()
- end
-
- def clear_notification(_forum, nil), do: nil
-
- def clear_notification(forum, user) do
- Notifications.delete_unread_notification("Forum", forum.id, user)
- end
end
diff --git a/lib/philomena/galleries.ex b/lib/philomena/galleries.ex
index 1943fc25..d36198b7 100644
--- a/lib/philomena/galleries.ex
+++ b/lib/philomena/galleries.ex
@@ -14,10 +14,12 @@ defmodule Philomena.Galleries do
alias Philomena.IndexWorker
alias Philomena.GalleryReorderWorker
alias Philomena.Notifications
- alias Philomena.NotificationWorker
- alias Philomena.Notifications.{Notification, UnreadNotification}
alias Philomena.Images
+ use Philomena.Subscriptions,
+ on_delete: :clear_gallery_notification,
+ id_name: :gallery_id
+
@doc """
Gets a single gallery.
@@ -91,21 +93,8 @@ defmodule Philomena.Galleries do
|> select([i], i.image_id)
|> Repo.all()
- unread_notifications =
- UnreadNotification
- |> join(:inner, [un], _ in assoc(un, :notification))
- |> where([_, n], n.actor_type == "Gallery")
- |> where([_, n], n.actor_id == ^gallery.id)
-
- notifications =
- Notification
- |> where(actor_type: "Gallery")
- |> where(actor_id: ^gallery.id)
-
Multi.new()
|> Multi.delete(:gallery, gallery)
- |> Multi.delete_all(:unread_notifications, unread_notifications)
- |> Multi.delete_all(:notifications, notifications)
|> Repo.transaction()
|> case do
{:ok, %{gallery: gallery}} ->
@@ -173,7 +162,7 @@ defmodule Philomena.Galleries do
def add_image_to_gallery(gallery, image) do
Multi.new()
- |> Multi.run(:lock, fn repo, %{} ->
+ |> Multi.run(:gallery, fn repo, %{} ->
gallery =
Gallery
|> where(id: ^gallery.id)
@@ -189,7 +178,7 @@ defmodule Philomena.Galleries do
|> Interaction.changeset(%{"image_id" => image.id, "position" => position})
|> repo.insert()
end)
- |> Multi.run(:gallery, fn repo, %{} ->
+ |> Multi.run(:image_count, fn repo, %{} ->
now = DateTime.utc_now()
{count, nil} =
@@ -199,11 +188,11 @@ defmodule Philomena.Galleries do
{:ok, count}
end)
+ |> Multi.run(:notification, ¬ify_gallery/2)
|> Repo.transaction()
|> case do
{:ok, result} ->
Images.reindex_image(image)
- notify_gallery(gallery, image)
reindex_gallery(gallery)
{:ok, result}
@@ -215,7 +204,7 @@ defmodule Philomena.Galleries do
def remove_image_from_gallery(gallery, image) do
Multi.new()
- |> Multi.run(:lock, fn repo, %{} ->
+ |> Multi.run(:gallery, fn repo, %{} ->
gallery =
Gallery
|> where(id: ^gallery.id)
@@ -232,7 +221,7 @@ defmodule Philomena.Galleries do
{:ok, count}
end)
- |> Multi.run(:gallery, fn repo, %{interaction: interaction_count} ->
+ |> Multi.run(:image_count, fn repo, %{interaction: interaction_count} ->
now = DateTime.utc_now()
{count, nil} =
@@ -255,37 +244,16 @@ defmodule Philomena.Galleries do
end
end
+ defp notify_gallery(_repo, %{gallery: gallery}) do
+ Notifications.create_gallery_image_notification(gallery)
+ end
+
defp last_position(gallery_id) do
Interaction
|> where(gallery_id: ^gallery_id)
|> Repo.aggregate(:max, :position)
end
- def notify_gallery(gallery, image) do
- Exq.enqueue(Exq, "notifications", NotificationWorker, ["Galleries", [gallery.id, image.id]])
- end
-
- def perform_notify([gallery_id, image_id]) do
- gallery = get_gallery!(gallery_id)
-
- subscriptions =
- gallery
- |> Repo.preload(:subscriptions)
- |> Map.fetch!(:subscriptions)
-
- Notifications.notify(
- gallery,
- subscriptions,
- %{
- actor_id: gallery.id,
- actor_type: "Gallery",
- actor_child_id: image_id,
- actor_child_type: "Image",
- action: "added images to"
- }
- )
- end
-
def reorder_gallery(gallery, image_ids) do
Exq.enqueue(Exq, "indexing", GalleryReorderWorker, [gallery.id, image_ids])
end
@@ -357,54 +325,17 @@ defmodule Philomena.Galleries do
defp position_order(%{order_position_asc: true}), do: [asc: :position]
defp position_order(_gallery), do: [desc: :position]
- alias Philomena.Galleries.Subscription
-
- def subscribed?(_gallery, nil), do: false
-
- def subscribed?(gallery, user) do
- Subscription
- |> where(gallery_id: ^gallery.id, user_id: ^user.id)
- |> Repo.exists?()
- end
-
@doc """
- Creates a subscription.
+ Removes all gallery notifications for a given gallery and user.
## Examples
- iex> create_subscription(%{field: value})
- {:ok, %Subscription{}}
-
- iex> create_subscription(%{field: bad_value})
- {:error, %Ecto.Changeset{}}
+ iex> clear_gallery_notification(gallery, user)
+ :ok
"""
- def create_subscription(gallery, user) do
- %Subscription{gallery_id: gallery.id, user_id: user.id}
- |> Subscription.changeset(%{})
- |> Repo.insert(on_conflict: :nothing)
- end
-
- @doc """
- Deletes a Subscription.
-
- ## Examples
-
- iex> delete_subscription(subscription)
- {:ok, %Subscription{}}
-
- iex> delete_subscription(subscription)
- {:error, %Ecto.Changeset{}}
-
- """
- def delete_subscription(gallery, user) do
- %Subscription{gallery_id: gallery.id, user_id: user.id}
- |> Repo.delete()
- end
-
- def clear_notification(_gallery, nil), do: nil
-
- def clear_notification(gallery, user) do
- Notifications.delete_unread_notification("Gallery", gallery.id, user)
+ def clear_gallery_notification(%Gallery{} = gallery, user) do
+ Notifications.clear_gallery_image_notification(gallery, user)
+ :ok
end
end
diff --git a/lib/philomena/galleries/query.ex b/lib/philomena/galleries/query.ex
index e04ceecc..ddfa1e8b 100644
--- a/lib/philomena/galleries/query.ex
+++ b/lib/philomena/galleries/query.ex
@@ -15,8 +15,6 @@ defmodule Philomena.Galleries.Query do
end
def compile(query_string) do
- query_string = query_string || ""
-
fields()
|> Parser.new()
|> Parser.parse(query_string)
diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex
index d68595fb..8559f42e 100644
--- a/lib/philomena/images.ex
+++ b/lib/philomena/images.ex
@@ -22,8 +22,9 @@ defmodule Philomena.Images do
alias Philomena.IndexWorker
alias Philomena.ImageFeatures.ImageFeature
alias Philomena.SourceChanges.SourceChange
- alias Philomena.Notifications.Notification
- alias Philomena.NotificationWorker
+ alias Philomena.Notifications.ImageCommentNotification
+ alias Philomena.Notifications.ImageMergeNotification
+ alias Philomena.TagChanges.Limits
alias Philomena.TagChanges.TagChange
alias Philomena.Tags
alias Philomena.UserStatistics
@@ -31,12 +32,15 @@ defmodule Philomena.Images do
alias Philomena.Notifications
alias Philomena.Interactions
alias Philomena.Reports
- alias Philomena.Reports.Report
alias Philomena.Comments
alias Philomena.Galleries.Gallery
alias Philomena.Galleries.Interaction
alias Philomena.Users.User
+ use Philomena.Subscriptions,
+ on_delete: :clear_image_notification,
+ id_name: :image_id
+
@doc """
Gets a single image.
@@ -90,11 +94,6 @@ defmodule Philomena.Images do
Multi.new()
|> Multi.insert(:image, image)
- |> Multi.run(:name_caches, fn repo, %{image: image} ->
- image
- |> Image.cache_changeset()
- |> repo.update()
- end)
|> Multi.run(:added_tag_count, fn repo, %{image: image} ->
tag_ids = image.added_tags |> Enum.map(& &1.id)
tags = Tag |> where([t], t.id in ^tag_ids)
@@ -103,7 +102,7 @@ defmodule Philomena.Images do
{:ok, count}
end)
- |> maybe_create_subscription_on_upload(attribution[:user])
+ |> maybe_subscribe_on(:image, attribution[:user], :watch_on_upload)
|> Repo.transaction()
|> case do
{:ok, %{image: image}} = result ->
@@ -157,17 +156,6 @@ defmodule Philomena.Images do
Logger.error("Aborting upload of #{image.id} after #{retry_count} retries")
end
- defp maybe_create_subscription_on_upload(multi, %User{watch_on_upload: true} = user) do
- multi
- |> Multi.run(:subscribe, fn _repo, %{image: image} ->
- create_subscription(image, user)
- end)
- end
-
- defp maybe_create_subscription_on_upload(multi, _user) do
- multi
- end
-
def approve_image(image) do
image
|> Repo.preload(:user)
@@ -201,8 +189,7 @@ defmodule Philomena.Images do
defp maybe_suggest_user_verification(%User{id: id, uploads_count: 5, verified: false}) do
Reports.create_system_report(
- id,
- "User",
+ {"User", id},
"Verification",
"User has uploaded enough approved images to be considered for verification."
)
@@ -376,7 +363,7 @@ defmodule Philomena.Images do
end
defp source_change_attributes(attribution, image, source, added, user) do
- now = DateTime.utc_now() |> DateTime.truncate(:second)
+ now = DateTime.utc_now(:second)
user_id =
case user do
@@ -392,8 +379,6 @@ defmodule Philomena.Images do
updated_at: now,
ip: attribution[:ip],
fingerprint: attribution[:fingerprint],
- user_agent: attribution[:user_agent],
- referrer: attribution[:referrer],
added: added
}
end
@@ -426,6 +411,9 @@ defmodule Philomena.Images do
error
end
end)
+ |> Multi.run(:check_limits, fn _repo, %{image: {image, _added, _removed}} ->
+ check_tag_change_limits_before_commit(image, attribution)
+ end)
|> Multi.run(:added_tag_changes, fn repo, %{image: {image, added_tags, _removed}} ->
tag_changes =
added_tags
@@ -469,10 +457,47 @@ defmodule Philomena.Images do
{:ok, count}
end)
|> Repo.transaction()
+ |> case do
+ {:ok, %{image: {image, _added, _removed}}} = res ->
+ update_tag_change_limits_after_commit(image, attribution)
+
+ res
+
+ err ->
+ err
+ end
+ end
+
+ defp check_tag_change_limits_before_commit(image, attribution) do
+ tag_changed_count = length(image.added_tags) + length(image.removed_tags)
+ rating_changed = image.ratings_changed
+ user = attribution[:user]
+ ip = attribution[:ip]
+
+ cond do
+ Limits.limited_for_tag_count?(user, ip, tag_changed_count) ->
+ {:error, :limit_exceeded}
+
+ rating_changed and Limits.limited_for_rating_count?(user, ip) ->
+ {:error, :limit_exceeded}
+
+ true ->
+ {:ok, 0}
+ end
+ end
+
+ def update_tag_change_limits_after_commit(image, attribution) do
+ rating_changed_count = if(image.ratings_changed, do: 1, else: 0)
+ tag_changed_count = length(image.added_tags) + length(image.removed_tags)
+ user = attribution[:user]
+ ip = attribution[:ip]
+
+ Limits.update_tag_count_after_update(user, ip, tag_changed_count)
+ Limits.update_rating_count_after_update(user, ip, rating_changed_count)
end
defp tag_change_attributes(attribution, image, tag, added, user) do
- now = DateTime.utc_now() |> DateTime.truncate(:second)
+ now = DateTime.utc_now(:second)
user_id =
case user do
@@ -489,8 +514,6 @@ defmodule Philomena.Images do
tag_name_cache: tag.name,
ip: attribution[:ip],
fingerprint: attribution[:fingerprint],
- user_agent: attribution[:user_agent],
- referrer: attribution[:referrer],
added: added
}
end
@@ -569,13 +592,13 @@ defmodule Philomena.Images do
|> Multi.run(:migrate_interactions, fn _, %{} ->
{:ok, Interactions.migrate_interactions(image, duplicate_of_image)}
end)
+ |> Multi.run(:notification, ¬ify_merge(&1, &2, image, duplicate_of_image))
|> Repo.transaction()
|> process_after_hide()
|> case do
{:ok, result} ->
reindex_image(duplicate_of_image)
Comments.reindex_comments(duplicate_of_image)
- notify_merge(image, duplicate_of_image)
{:ok, result}
@@ -585,11 +608,7 @@ defmodule Philomena.Images do
end
defp hide_image_multi(changeset, image, user, multi) do
- reports =
- Report
- |> where(reportable_type: "Image", reportable_id: ^image.id)
- |> select([r], r.id)
- |> update(set: [open: false, state: "closed", admin_id: ^user.id])
+ report_query = Reports.close_report_query({"Image", image.id}, user)
galleries =
Gallery
@@ -600,7 +619,7 @@ defmodule Philomena.Images do
multi
|> Multi.update(:image, changeset)
- |> Multi.update_all(:reports, reports, [])
+ |> Multi.update_all(:reports, report_query, [])
|> Multi.update_all(:galleries, galleries, [])
|> Multi.delete_all(:gallery_interactions, gallery_interactions, [])
|> Multi.run(:tags, fn repo, %{image: image} ->
@@ -715,7 +734,7 @@ defmodule Philomena.Images do
|> where([t], t.image_id in ^image_ids and t.tag_id in ^removed_tags)
|> select([t], [t.image_id, t.tag_id])
- now = DateTime.utc_now() |> DateTime.truncate(:second)
+ now = DateTime.utc_now(:second)
tag_change_attributes = Map.merge(tag_change_attributes, %{created_at: now, updated_at: now})
tag_attributes = %{name: "", slug: "", created_at: now, updated_at: now}
@@ -868,53 +887,6 @@ defmodule Philomena.Images do
alias Philomena.Images.Subscription
- def subscribed?(_image, nil), do: false
-
- def subscribed?(image, user) do
- Subscription
- |> where(image_id: ^image.id, user_id: ^user.id)
- |> Repo.exists?()
- end
-
- @doc """
- Creates a subscription.
-
- ## Examples
-
- iex> create_subscription(%{field: value})
- {:ok, %Subscription{}}
-
- iex> create_subscription(%{field: bad_value})
- {:error, %Ecto.Changeset{}}
-
- """
- def create_subscription(_image, nil), do: {:ok, nil}
-
- def create_subscription(image, user) do
- %Subscription{image_id: image.id, user_id: user.id}
- |> Subscription.changeset(%{})
- |> Repo.insert(on_conflict: :nothing)
- end
-
- @doc """
- Deletes a subscription.
-
- ## Examples
-
- iex> delete_subscription(subscription)
- {:ok, %Subscription{}}
-
- iex> delete_subscription(subscription)
- {:error, %Ecto.Changeset{}}
-
- """
- def delete_subscription(image, user) do
- clear_notification(image, user)
-
- %Subscription{image_id: image.id, user_id: user.id}
- |> Repo.delete()
- end
-
def migrate_subscriptions(source, target) do
subscriptions =
Subscription
@@ -924,12 +896,40 @@ defmodule Philomena.Images do
Repo.insert_all(Subscription, subscriptions, on_conflict: :nothing)
- {count, nil} =
- Notification
- |> where(actor_type: "Image", actor_id: ^source.id)
- |> Repo.delete_all()
+ comment_notifications =
+ from cn in ImageCommentNotification,
+ where: cn.image_id == ^source.id,
+ select: %{
+ user_id: cn.user_id,
+ image_id: ^target.id,
+ comment_id: cn.comment_id,
+ read: cn.read,
+ created_at: cn.created_at,
+ updated_at: cn.updated_at
+ }
- {:ok, count}
+ merge_notifications =
+ from mn in ImageMergeNotification,
+ where: mn.target_id == ^source.id,
+ select: %{
+ user_id: mn.user_id,
+ target_id: ^target.id,
+ source_id: mn.source_id,
+ read: mn.read,
+ created_at: mn.created_at,
+ updated_at: mn.updated_at
+ }
+
+ {comment_notification_count, nil} =
+ Repo.insert_all(ImageCommentNotification, comment_notifications, on_conflict: :nothing)
+
+ {merge_notification_count, nil} =
+ Repo.insert_all(ImageMergeNotification, merge_notifications, on_conflict: :nothing)
+
+ Repo.delete_all(exclude(comment_notifications, :select))
+ Repo.delete_all(exclude(merge_notifications, :select))
+
+ {:ok, {comment_notification_count, merge_notification_count}}
end
def migrate_sources(source, target) do
@@ -944,34 +944,22 @@ defmodule Philomena.Images do
|> Repo.update()
end
- def notify_merge(source, target) do
- Exq.enqueue(Exq, "notifications", NotificationWorker, ["Images", [source.id, target.id]])
+ defp notify_merge(_repo, _changes, source, target) do
+ Notifications.create_image_merge_notification(target, source)
end
- def perform_notify([source_id, target_id]) do
- target = get_image!(target_id)
+ @doc """
+ Removes all image notifications for a given image and user.
- subscriptions =
- target
- |> Repo.preload(:subscriptions)
- |> Map.fetch!(:subscriptions)
+ ## Examples
- Notifications.notify(
- nil,
- subscriptions,
- %{
- actor_id: target.id,
- actor_type: "Image",
- actor_child_id: nil,
- actor_child_type: nil,
- action: "merged ##{source_id} into"
- }
- )
- end
+ iex> clear_image_notification(image, user)
+ :ok
- def clear_notification(_image, nil), do: nil
-
- def clear_notification(image, user) do
- Notifications.delete_unread_notification("Image", image.id, user)
+ """
+ def clear_image_notification(%Image{} = image, user) do
+ Notifications.clear_image_comment_notification(image, user)
+ Notifications.clear_image_merge_notification(image, user)
+ :ok
end
end
diff --git a/lib/philomena/images/image.ex b/lib/philomena/images/image.ex
index 7b808eaa..e02356dd 100644
--- a/lib/philomena/images/image.ex
+++ b/lib/philomena/images/image.ex
@@ -2,7 +2,6 @@ defmodule Philomena.Images.Image do
use Ecto.Schema
import Ecto.Changeset
- import Ecto.Query
alias Philomena.ImageIntensities.ImageIntensity
alias Philomena.ImageVotes.ImageVote
@@ -51,6 +50,7 @@ defmodule Philomena.Images.Image do
field :image_width, :integer
field :image_height, :integer
field :image_size, :integer
+ field :image_orig_size, :integer
field :image_format, :string
field :image_mime_type, :string
field :image_aspect_ratio, :float
@@ -58,14 +58,11 @@ defmodule Philomena.Images.Image do
field :image_is_animated, :boolean, source: :is_animated
field :ip, EctoNetwork.INET
field :fingerprint, :string
- field :user_agent, :string, default: ""
- field :referrer, :string, default: ""
field :anonymous, :boolean, default: false
field :score, :integer, default: 0
field :faves_count, :integer, default: 0
field :upvotes_count, :integer, default: 0
field :downvotes_count, :integer, default: 0
- field :votes_count, :integer, default: 0
field :source_url, :string
field :description, :string, default: ""
field :image_sha512_hash, :string
@@ -87,15 +84,11 @@ defmodule Philomena.Images.Image do
field :hides_count, :integer, default: 0
field :approved, :boolean
- # todo: can probably remove these now
- field :tag_list_cache, :string
- field :tag_list_plus_alias_cache, :string
- field :file_name_cache, :string
-
field :removed_tags, {:array, :any}, default: [], virtual: true
field :added_tags, {:array, :any}, default: [], virtual: true
field :removed_sources, {:array, :any}, default: [], virtual: true
field :added_sources, {:array, :any}, default: [], virtual: true
+ field :ratings_changed, :boolean, default: false, virtual: true
field :uploaded_image, :string, virtual: true
field :removed_image, :string, virtual: true
@@ -120,11 +113,9 @@ defmodule Philomena.Images.Image do
end
def creation_changeset(image, attrs, attribution) do
- now = DateTime.utc_now() |> DateTime.truncate(:second)
-
image
|> cast(attrs, [:anonymous, :source_url, :description])
- |> change(first_seen_at: now)
+ |> change(first_seen_at: DateTime.utc_now(:second))
|> change(attribution)
|> validate_length(:description, max: 50_000, count: :bytes)
|> validate_format(:source_url, ~r/\Ahttps?:\/\//)
@@ -138,6 +129,7 @@ defmodule Philomena.Images.Image do
:image_width,
:image_height,
:image_size,
+ :image_orig_size,
:image_format,
:image_mime_type,
:image_aspect_ratio,
@@ -153,6 +145,7 @@ defmodule Philomena.Images.Image do
:image_width,
:image_height,
:image_size,
+ :image_orig_size,
:image_format,
:image_mime_type,
:image_aspect_ratio,
@@ -226,7 +219,6 @@ defmodule Philomena.Images.Image do
|> cast(attrs, [])
|> TagDiffer.diff_input(old_tags, new_tags, excluded_tags)
|> TagValidator.validate_tags()
- |> cache_changeset()
end
def locked_tags_changeset(image, attrs, locked_tags) do
@@ -340,54 +332,7 @@ defmodule Philomena.Images.Image do
def approve_changeset(image) do
change(image)
|> put_change(:approved, true)
- |> put_change(:first_seen_at, DateTime.truncate(DateTime.utc_now(), :second))
- end
-
- def cache_changeset(image) do
- changeset = change(image)
- image = apply_changes(changeset)
-
- {tag_list_cache, tag_list_plus_alias_cache, file_name_cache} =
- create_caches(image.id, image.tags)
-
- changeset
- |> put_change(:tag_list_cache, tag_list_cache)
- |> put_change(:tag_list_plus_alias_cache, tag_list_plus_alias_cache)
- |> put_change(:file_name_cache, file_name_cache)
- end
-
- defp create_caches(image_id, tags) do
- tags = Tag.display_order(tags)
-
- tag_list_cache =
- tags
- |> Enum.map_join(", ", & &1.name)
-
- tag_ids = tags |> Enum.map(& &1.id)
-
- aliases =
- Tag
- |> where([t], t.aliased_tag_id in ^tag_ids)
- |> Repo.all()
-
- tag_list_plus_alias_cache =
- (tags ++ aliases)
- |> Tag.display_order()
- |> Enum.map_join(", ", & &1.name)
-
- # Truncate filename to 150 characters, making room for the path + filename on Windows
- # https://stackoverflow.com/questions/265769/maximum-filename-length-in-ntfs-windows-xp-and-windows-vista
- file_name_slug_fragment =
- tags
- |> Enum.map_join("_", & &1.slug)
- |> String.to_charlist()
- |> Enum.filter(&(&1 in ?a..?z or &1 in ~c"0123456789_-"))
- |> List.to_string()
- |> String.slice(0..150)
-
- file_name_cache = "#{image_id}__#{file_name_slug_fragment}"
-
- {tag_list_cache, tag_list_plus_alias_cache, file_name_cache}
+ |> put_change(:first_seen_at, DateTime.utc_now(:second))
end
defp create_key do
diff --git a/lib/philomena/images/query.ex b/lib/philomena/images/query.ex
index 9eedcd74..d63fa789 100644
--- a/lib/philomena/images/query.ex
+++ b/lib/philomena/images/query.ex
@@ -84,7 +84,7 @@ defmodule Philomena.Images.Query do
defp anonymous_fields do
[
int_fields:
- ~W(id width height score upvotes downvotes faves uploader_id faved_by_id pixels size comment_count source_count tag_count) ++
+ ~W(id width height score upvotes downvotes faves uploader_id faved_by_id pixels size orig_size comment_count source_count tag_count) ++
tag_count_fields(),
float_fields: ~W(aspect_ratio wilson_score duration),
date_fields: ~W(created_at updated_at first_seen_at),
@@ -144,8 +144,9 @@ defmodule Philomena.Images.Query do
|> Parser.parse(query_string, context)
end
- def compile(user, query_string, watch \\ false) do
- query_string = query_string || ""
+ def compile(query_string, opts \\ []) do
+ user = Keyword.get(opts, :user)
+ watch = Keyword.get(opts, :watch, false)
case user do
nil ->
diff --git a/lib/philomena/images/search_index.ex b/lib/philomena/images/search_index.ex
index 2d9265b5..35241ccd 100644
--- a/lib/philomena/images/search_index.ex
+++ b/lib/philomena/images/search_index.ex
@@ -54,6 +54,7 @@ defmodule Philomena.Images.SearchIndex do
processed: %{type: "boolean"},
score: %{type: "integer"},
size: %{type: "integer"},
+ orig_size: %{type: "integer"},
sha512_hash: %{type: "keyword"},
source_url: %{type: "keyword"},
source_count: %{type: "integer"},
@@ -117,6 +118,7 @@ defmodule Philomena.Images.SearchIndex do
height: image.image_height,
pixels: image.image_width * image.image_height,
size: image.image_size,
+ orig_size: image.image_orig_size,
animated: image.image_is_animated,
duration: if(image.image_is_animated, do: image.image_duration, else: 0),
tag_count: length(image.tags),
diff --git a/lib/philomena/images/source.ex b/lib/philomena/images/source.ex
index 476872b9..3936383f 100644
--- a/lib/philomena/images/source.ex
+++ b/lib/philomena/images/source.ex
@@ -13,7 +13,9 @@ defmodule Philomena.Images.Source do
@doc false
def changeset(source, attrs) do
source
- |> cast(attrs, [])
- |> validate_required([])
+ |> cast(attrs, [:source])
+ |> validate_required([:source])
+ |> validate_format(:source, ~r/\Ahttps?:\/\//)
+ |> validate_length(:source, max: 255)
end
end
diff --git a/lib/philomena/images/source_differ.ex b/lib/philomena/images/source_differ.ex
index 8ac29a08..31a3b5b8 100644
--- a/lib/philomena/images/source_differ.ex
+++ b/lib/philomena/images/source_differ.ex
@@ -1,6 +1,5 @@
defmodule Philomena.Images.SourceDiffer do
import Ecto.Changeset
- alias Philomena.Images.Source
def diff_input(changeset, old_sources, new_sources) do
old_set = MapSet.new(flatten_input(old_sources))
@@ -13,12 +12,11 @@ defmodule Philomena.Images.SourceDiffer do
{sources, actually_added, actually_removed} =
apply_changes(source_set, added_sources, removed_sources)
- image_id = fetch_field!(changeset, :id)
-
changeset
+ |> cast(source_params(sources), [])
|> put_change(:added_sources, actually_added)
|> put_change(:removed_sources, actually_removed)
- |> put_assoc(:sources, source_structs(image_id, sources))
+ |> cast_assoc(:sources)
end
defp apply_changes(source_set, added_set, removed_set) do
@@ -44,8 +42,8 @@ defmodule Philomena.Images.SourceDiffer do
{sources, actually_added, actually_removed}
end
- defp source_structs(image_id, sources) do
- Enum.map(sources, &%Source{image_id: image_id, source: &1})
+ defp source_params(sources) do
+ %{sources: Enum.map(sources, &%{source: &1})}
end
defp flatten_input(input) when is_map(input) do
diff --git a/lib/philomena/images/tag_validator.ex b/lib/philomena/images/tag_validator.ex
index 887b5daa..8ffe3bdb 100644
--- a/lib/philomena/images/tag_validator.ex
+++ b/lib/philomena/images/tag_validator.ex
@@ -5,7 +5,20 @@ defmodule Philomena.Images.TagValidator do
def validate_tags(changeset) do
tags = changeset |> get_field(:tags)
- validate_tag_input(changeset, tags)
+ changeset
+ |> validate_tag_input(tags)
+ |> set_rating_changed()
+ end
+
+ defp set_rating_changed(changeset) do
+ added_tags = changeset |> get_field(:added_tags) |> extract_names()
+ removed_tags = changeset |> get_field(:removed_tags) |> extract_names()
+ ratings = all_ratings()
+
+ added_ratings = MapSet.intersection(ratings, added_tags) |> MapSet.size()
+ removed_ratings = MapSet.intersection(ratings, removed_tags) |> MapSet.size()
+
+ put_change(changeset, :ratings_changed, added_ratings + removed_ratings > 0)
end
defp validate_tag_input(changeset, tags) do
@@ -108,6 +121,13 @@ defmodule Philomena.Images.TagValidator do
|> MapSet.new()
end
+ defp all_ratings do
+ safe_rating()
+ |> MapSet.union(sexual_ratings())
+ |> MapSet.union(horror_ratings())
+ |> MapSet.union(gross_rating())
+ end
+
defp safe_rating, do: MapSet.new(["safe"])
defp sexual_ratings, do: MapSet.new(["suggestive", "questionable", "explicit"])
defp horror_ratings, do: MapSet.new(["semi-grimdark", "grimdark"])
diff --git a/lib/philomena/images/thumbnailer.ex b/lib/philomena/images/thumbnailer.ex
index 8c566135..b8be742b 100644
--- a/lib/philomena/images/thumbnailer.ex
+++ b/lib/philomena/images/thumbnailer.ex
@@ -76,7 +76,7 @@ defmodule Philomena.Images.Thumbnailer do
def generate_thumbnails(image_id) do
image = Repo.get!(Image, image_id)
file = download_image_file(image)
- {:ok, analysis} = Analyzers.analyze(file)
+ {:ok, analysis} = Analyzers.analyze_path(file)
file =
apply_edit_script(image, file, Processors.process(analysis, file, generated_sizes(image)))
@@ -127,7 +127,7 @@ defmodule Philomena.Images.Thumbnailer do
end
defp recompute_meta(image, file, changeset_fn) do
- {:ok, %{dimensions: {width, height}}} = Analyzers.analyze(file)
+ {:ok, %{dimensions: {width, height}}} = Analyzers.analyze_path(file)
image
|> changeset_fn.(%{
diff --git a/lib/philomena/interactions.ex b/lib/philomena/interactions.ex
index 5ed5c8f2..8da603ba 100644
--- a/lib/philomena/interactions.ex
+++ b/lib/philomena/interactions.ex
@@ -72,7 +72,7 @@ defmodule Philomena.Interactions do
end
def migrate_interactions(source, target) do
- now = DateTime.utc_now() |> DateTime.truncate(:second)
+ now = DateTime.utc_now(:second)
source = Repo.preload(source, [:hiders, :favers, :upvoters, :downvoters])
new_hides = Enum.map(source.hiders, &%{image_id: target.id, user_id: &1.id, created_at: now})
diff --git a/lib/philomena/mod_notes.ex b/lib/philomena/mod_notes.ex
index 9ae9313b..450216b0 100644
--- a/lib/philomena/mod_notes.ex
+++ b/lib/philomena/mod_notes.ex
@@ -7,18 +7,82 @@ defmodule Philomena.ModNotes do
alias Philomena.Repo
alias Philomena.ModNotes.ModNote
+ alias Philomena.Polymorphic
@doc """
- Returns the list of mod_notes.
+ Returns a list of 2-tuples of mod notes and rendered output for the notable type and id.
+
+ See `list_mod_notes/3` for more information about collection rendering.
## Examples
- iex> list_mod_notes()
- [%ModNote{}, ...]
+ iex> list_all_mod_notes_by_type_and_id("User", "1", & &1.body)
+ [
+ {%ModNote{body: "hello *world*"}, "hello *world*"}
+ ]
"""
- def list_mod_notes do
- Repo.all(ModNote)
+ def list_all_mod_notes_by_type_and_id(notable_type, notable_id, collection_renderer) do
+ ModNote
+ |> where(notable_type: ^notable_type, notable_id: ^notable_id)
+ |> preload(:moderator)
+ |> order_by(desc: :id)
+ |> Repo.all()
+ |> preload_and_render(collection_renderer)
+ end
+
+ @doc """
+ Returns a `m:Scrivener.Page` of 2-tuples of mod notes and rendered output
+ for the query string and current pagination.
+
+ All mod notes containing the substring `query_string` are matched and returned
+ case-insensitively.
+
+ See `list_mod_notes/3` for more information.
+
+ ## Examples
+
+ iex> list_mod_notes_by_query_string("quack", & &1.body, page_size: 15)
+ %Scrivener.Page{}
+
+ """
+ def list_mod_notes_by_query_string(query_string, collection_renderer, pagination) do
+ ModNote
+ |> where([m], ilike(m.body, ^"%#{query_string}%"))
+ |> list_mod_notes(collection_renderer, pagination)
+ end
+
+ @doc """
+ Returns a `m:Scrivener.Page` of 2-tuples of mod notes and rendered output
+ for the current pagination.
+
+ When coerced to a list and rendered as Markdown, the result may look like:
+
+ [
+ {%ModNote{body: "hello *world*"}, "hello world"}
+ ]
+
+ ## Examples
+
+ iex> list_mod_notes(& &1.body, page_size: 15)
+ %Scrivener.Page{}
+
+ """
+ def list_mod_notes(queryable \\ ModNote, collection_renderer, pagination) do
+ mod_notes =
+ queryable
+ |> preload(:moderator)
+ |> order_by(desc: :id)
+ |> Repo.paginate(pagination)
+
+ put_in(mod_notes.entries, preload_and_render(mod_notes, collection_renderer))
+ end
+
+ defp preload_and_render(mod_notes, collection_renderer) do
+ bodies = collection_renderer.(mod_notes)
+ preloaded = Polymorphic.load_polymorphic(mod_notes, notable: [notable_id: :notable_type])
+
+ Enum.zip(preloaded, bodies)
end
@doc """
diff --git a/lib/philomena/moderation_logs.ex b/lib/philomena/moderation_logs.ex
index dcdb53f4..b84d37ad 100644
--- a/lib/philomena/moderation_logs.ex
+++ b/lib/philomena/moderation_logs.ex
@@ -9,40 +9,24 @@ defmodule Philomena.ModerationLogs do
alias Philomena.ModerationLogs.ModerationLog
@doc """
- Returns the list of moderation_logs.
+ Returns a paginated list of moderation logs as a `m:Scrivener.Page`.
## Examples
- iex> list_moderation_logs()
+ iex> list_moderation_logs(page_size: 15)
[%ModerationLog{}, ...]
"""
- def list_moderation_logs(conn) do
+ def list_moderation_logs(pagination) do
ModerationLog
- |> where([ml], ml.created_at > ago(2, "week"))
+ |> where([ml], ml.created_at >= ago(2, "week"))
|> preload(:user)
|> order_by(desc: :created_at)
- |> Repo.paginate(conn.assigns.scrivener)
+ |> Repo.paginate(pagination)
end
@doc """
- Gets a single moderation_log.
-
- Raises `Ecto.NoResultsError` if the Moderation log does not exist.
-
- ## Examples
-
- iex> get_moderation_log!(123)
- %ModerationLog{}
-
- iex> get_moderation_log!(456)
- ** (Ecto.NoResultsError)
-
- """
- def get_moderation_log!(id), do: Repo.get!(ModerationLog, id)
-
- @doc """
- Creates a moderation_log.
+ Creates a moderation log.
## Examples
@@ -60,21 +44,14 @@ defmodule Philomena.ModerationLogs do
end
@doc """
- Deletes a moderation_log.
+ Removes moderation logs created more than 2 weeks ago.
## Examples
- iex> delete_moderation_log(moderation_log)
- {:ok, %ModerationLog{}}
-
- iex> delete_moderation_log(moderation_log)
- {:error, %Ecto.Changeset{}}
+ iex> cleanup!()
+ {31, nil}
"""
- def delete_moderation_log(%ModerationLog{} = moderation_log) do
- Repo.delete(moderation_log)
- end
-
def cleanup! do
ModerationLog
|> where([ml], ml.created_at < ago(2, "week"))
diff --git a/lib/philomena/notifications.ex b/lib/philomena/notifications.ex
index a82094b3..1adae26e 100644
--- a/lib/philomena/notifications.ex
+++ b/lib/philomena/notifications.ex
@@ -6,214 +6,291 @@ defmodule Philomena.Notifications do
import Ecto.Query, warn: false
alias Philomena.Repo
- alias Philomena.Notifications.Notification
+ alias Philomena.Channels.Subscription, as: ChannelSubscription
+ alias Philomena.Forums.Subscription, as: ForumSubscription
+ alias Philomena.Galleries.Subscription, as: GallerySubscription
+ alias Philomena.Images.Subscription, as: ImageSubscription
+ alias Philomena.Topics.Subscription, as: TopicSubscription
+
+ alias Philomena.Notifications.ChannelLiveNotification
+ alias Philomena.Notifications.ForumPostNotification
+ alias Philomena.Notifications.ForumTopicNotification
+ alias Philomena.Notifications.GalleryImageNotification
+ alias Philomena.Notifications.ImageCommentNotification
+ alias Philomena.Notifications.ImageMergeNotification
+
+ alias Philomena.Notifications.Category
+ alias Philomena.Notifications.Creator
@doc """
- Returns the list of notifications.
+ Return the count of all currently unread notifications for the user in all categories.
## Examples
- iex> list_notifications()
- [%Notification{}, ...]
+ iex> total_unread_notification_count(user)
+ 15
"""
- def list_notifications do
- Repo.all(Notification)
+ def total_unread_notification_count(user) do
+ Category.total_unread_notification_count(user)
end
@doc """
- Gets a single notification.
-
- Raises `Ecto.NoResultsError` if the Notification does not exist.
+ Gather up and return the top N notifications for the user, for each category of
+ unread notification currently existing.
## Examples
- iex> get_notification!(123)
- %Notification{}
-
- iex> get_notification!(456)
- ** (Ecto.NoResultsError)
+ iex> unread_notifications_for_user(user, page_size: 10)
+ [
+ channel_live: [],
+ forum_post: [%ForumPostNotification{...}, ...],
+ forum_topic: [%ForumTopicNotification{...}, ...],
+ gallery_image: [],
+ image_comment: [%ImageCommentNotification{...}, ...],
+ image_merge: []
+ ]
"""
- def get_notification!(id), do: Repo.get!(Notification, id)
-
- @doc """
- Creates a notification.
-
- ## Examples
-
- iex> create_notification(%{field: value})
- {:ok, %Notification{}}
-
- iex> create_notification(%{field: bad_value})
- {:error, %Ecto.Changeset{}}
-
- """
- def create_notification(attrs \\ %{}) do
- %Notification{}
- |> Notification.changeset(attrs)
- |> Repo.insert()
+ def unread_notifications_for_user(user, pagination) do
+ Category.unread_notifications_for_user(user, pagination)
end
@doc """
- Updates a notification.
+ Returns paginated unread notifications for the user, given the category.
## Examples
- iex> update_notification(notification, %{field: new_value})
- {:ok, %Notification{}}
-
- iex> update_notification(notification, %{field: bad_value})
- {:error, %Ecto.Changeset{}}
+ iex> unread_notifications_for_user_and_category(user, :image_comment)
+ [%ImageCommentNotification{...}]
"""
- def update_notification(%Notification{} = notification, attrs) do
- notification
- |> Notification.changeset(attrs)
- |> Repo.insert_or_update()
+ def unread_notifications_for_user_and_category(user, category, pagination) do
+ Category.unread_notifications_for_user_and_category(user, category, pagination)
end
@doc """
- Deletes a Notification.
+ Creates a channel live notification, returning the number of affected users.
## Examples
- iex> delete_notification(notification)
- {:ok, %Notification{}}
-
- iex> delete_notification(notification)
- {:error, %Ecto.Changeset{}}
+ iex> create_channel_live_notification(channel)
+ {:ok, 2}
"""
- def delete_notification(%Notification{} = notification) do
- Repo.delete(notification)
+ def create_channel_live_notification(channel) do
+ Creator.broadcast_notification(
+ from: {ChannelSubscription, channel_id: channel.id},
+ into: ChannelLiveNotification,
+ select: [channel_id: channel.id],
+ unique_key: :channel_id
+ )
end
@doc """
- Returns an `%Ecto.Changeset{}` for tracking notification changes.
+ Creates a forum post notification, returning the number of affected users.
## Examples
- iex> change_notification(notification)
- %Ecto.Changeset{source: %Notification{}}
+ iex> create_forum_post_notification(user, topic, post)
+ {:ok, 2}
"""
- def change_notification(%Notification{} = notification) do
- Notification.changeset(notification, %{})
- end
-
- alias Philomena.Notifications.UnreadNotification
-
- def count_unread_notifications(user) do
- UnreadNotification
- |> where(user_id: ^user.id)
- |> Repo.aggregate(:count, :notification_id)
+ def create_forum_post_notification(user, topic, post) do
+ Creator.broadcast_notification(
+ notification_author: user,
+ from: {TopicSubscription, topic_id: topic.id},
+ into: ForumPostNotification,
+ select: [topic_id: topic.id, post_id: post.id],
+ unique_key: :topic_id
+ )
end
@doc """
- Creates a unread_notification.
+ Creates a forum topic notification, returning the number of affected users.
## Examples
- iex> create_unread_notification(%{field: value})
- {:ok, %UnreadNotification{}}
-
- iex> create_unread_notification(%{field: bad_value})
- {:error, %Ecto.Changeset{}}
+ iex> create_forum_topic_notification(user, topic)
+ {:ok, 2}
"""
- def create_unread_notification(attrs \\ %{}) do
- %UnreadNotification{}
- |> UnreadNotification.changeset(attrs)
- |> Repo.insert()
+ def create_forum_topic_notification(user, topic) do
+ Creator.broadcast_notification(
+ notification_author: user,
+ from: {ForumSubscription, forum_id: topic.forum_id},
+ into: ForumTopicNotification,
+ select: [topic_id: topic.id],
+ unique_key: :topic_id
+ )
end
@doc """
- Updates a unread_notification.
+ Creates a gallery image notification, returning the number of affected users.
## Examples
- iex> update_unread_notification(unread_notification, %{field: new_value})
- {:ok, %UnreadNotification{}}
-
- iex> update_unread_notification(unread_notification, %{field: bad_value})
- {:error, %Ecto.Changeset{}}
+ iex> create_gallery_image_notification(gallery)
+ {:ok, 2}
"""
- def update_unread_notification(%UnreadNotification{} = unread_notification, attrs) do
- unread_notification
- |> UnreadNotification.changeset(attrs)
- |> Repo.update()
+ def create_gallery_image_notification(gallery) do
+ Creator.broadcast_notification(
+ from: {GallerySubscription, gallery_id: gallery.id},
+ into: GalleryImageNotification,
+ select: [gallery_id: gallery.id],
+ unique_key: :gallery_id
+ )
end
@doc """
- Deletes a UnreadNotification.
+ Creates an image comment notification, returning the number of affected users.
## Examples
- iex> delete_unread_notification(unread_notification)
- {:ok, %UnreadNotification{}}
-
- iex> delete_unread_notification(unread_notification)
- {:error, %Ecto.Changeset{}}
+ iex> create_image_comment_notification(user, image, comment)
+ {:ok, 2}
"""
- def delete_unread_notification(actor_type, actor_id, user) do
- notification =
- Notification
- |> where(actor_type: ^actor_type, actor_id: ^actor_id)
- |> Repo.one()
+ def create_image_comment_notification(user, image, comment) do
+ Creator.broadcast_notification(
+ notification_author: user,
+ from: {ImageSubscription, image_id: image.id},
+ into: ImageCommentNotification,
+ select: [image_id: image.id, comment_id: comment.id],
+ unique_key: :image_id
+ )
+ end
- if notification do
- UnreadNotification
- |> where(notification_id: ^notification.id, user_id: ^user.id)
- |> Repo.delete_all()
+ @doc """
+ Creates an image merge notification, returning the number of affected users.
+
+ ## Examples
+
+ iex> create_image_merge_notification(target, source)
+ {:ok, 2}
+
+ """
+ def create_image_merge_notification(target, source) do
+ Creator.broadcast_notification(
+ from: {ImageSubscription, image_id: target.id},
+ into: ImageMergeNotification,
+ select: [target_id: target.id, source_id: source.id],
+ unique_key: :target_id
+ )
+ end
+
+ @doc """
+ Removes the channel live notification for a given channel and user, returning
+ the number of affected users.
+
+ ## Examples
+
+ iex> clear_channel_live_notification(channel, user)
+ {:ok, 2}
+
+ """
+ def clear_channel_live_notification(channel, user) do
+ ChannelLiveNotification
+ |> where(channel_id: ^channel.id)
+ |> delete_all_for_user(user)
+ end
+
+ @doc """
+ Removes the forum post notification for a given topic and user, returning
+ the number of affected notifications.
+
+ ## Examples
+
+ iex> clear_forum_post_notification(topic, user)
+ {:ok, 2}
+
+ """
+ def clear_forum_post_notification(topic, user) do
+ ForumPostNotification
+ |> where(topic_id: ^topic.id)
+ |> delete_all_for_user(user)
+ end
+
+ @doc """
+ Removes the forum topic notification for a given topic and user, returning
+ the number of affected notifications.
+
+ ## Examples
+
+ iex> clear_forum_topic_notification(topic, user)
+ {:ok, 2}
+
+ """
+ def clear_forum_topic_notification(topic, user) do
+ ForumTopicNotification
+ |> where(topic_id: ^topic.id)
+ |> delete_all_for_user(user)
+ end
+
+ @doc """
+ Removes the gallery image notification for a given gallery and user, returning
+ the number of affected notifications.
+
+ ## Examples
+
+ iex> clear_gallery_image_notification(topic, user)
+ {:ok, 2}
+
+ """
+ def clear_gallery_image_notification(gallery, user) do
+ GalleryImageNotification
+ |> where(gallery_id: ^gallery.id)
+ |> delete_all_for_user(user)
+ end
+
+ @doc """
+ Removes the image comment notification for a given image and user, returning
+ the number of affected notifications.
+
+ ## Examples
+
+ iex> clear_gallery_image_notification(topic, user)
+ {:ok, 2}
+
+ """
+ def clear_image_comment_notification(image, user) do
+ ImageCommentNotification
+ |> where(image_id: ^image.id)
+ |> delete_all_for_user(user)
+ end
+
+ @doc """
+ Removes the image merge notification for a given image and user, returning
+ the number of affected notifications.
+
+ ## Examples
+
+ iex> clear_image_merge_notification(topic, user)
+ {:ok, 2}
+
+ """
+ def clear_image_merge_notification(image, user) do
+ ImageMergeNotification
+ |> where(target_id: ^image.id)
+ |> delete_all_for_user(user)
+ end
+
+ #
+ # Clear all unread notifications using the given query.
+ #
+ # Returns `{:ok, count}`, where `count` is the number of affected rows.
+ #
+ defp delete_all_for_user(query, user) do
+ if user do
+ {count, nil} =
+ query
+ |> where(user_id: ^user.id)
+ |> Repo.delete_all()
+
+ {:ok, count}
+ else
+ {:ok, 0}
end
end
-
- @doc """
- Returns an `%Ecto.Changeset{}` for tracking unread_notification changes.
-
- ## Examples
-
- iex> change_unread_notification(unread_notification)
- %Ecto.Changeset{source: %UnreadNotification{}}
-
- """
- def change_unread_notification(%UnreadNotification{} = unread_notification) do
- UnreadNotification.changeset(unread_notification, %{})
- end
-
- def notify(_actor_child, [], _params), do: nil
-
- def notify(actor_child, subscriptions, params) do
- # Don't push to the user that created the notification
- subscriptions =
- case actor_child do
- %{user_id: id} ->
- subscriptions
- |> Enum.reject(&(&1.user_id == id))
-
- _ ->
- subscriptions
- end
-
- Repo.transaction(fn ->
- notification =
- Notification
- |> Repo.get_by(actor_id: params.actor_id, actor_type: params.actor_type)
-
- {:ok, notification} =
- (notification || %Notification{})
- |> update_notification(params)
-
- # Insert the notification to any watchers who do not have it
- unreads =
- subscriptions
- |> Enum.map(&%{user_id: &1.user_id, notification_id: notification.id})
-
- UnreadNotification
- |> Repo.insert_all(unreads, on_conflict: :nothing)
- end)
- end
end
diff --git a/lib/philomena/notifications/category.ex b/lib/philomena/notifications/category.ex
new file mode 100644
index 00000000..b16649c0
--- /dev/null
+++ b/lib/philomena/notifications/category.ex
@@ -0,0 +1,166 @@
+defmodule Philomena.Notifications.Category do
+ @moduledoc """
+ Notification category querying.
+ """
+
+ import Ecto.Query, warn: false
+ alias Philomena.Repo
+
+ alias Philomena.Notifications.ChannelLiveNotification
+ alias Philomena.Notifications.ForumPostNotification
+ alias Philomena.Notifications.ForumTopicNotification
+ alias Philomena.Notifications.GalleryImageNotification
+ alias Philomena.Notifications.ImageCommentNotification
+ alias Philomena.Notifications.ImageMergeNotification
+
+ @type t ::
+ :channel_live
+ | :forum_post
+ | :forum_topic
+ | :gallery_image
+ | :image_comment
+ | :image_merge
+
+ @doc """
+ Return a list of all supported categories.
+ """
+ def categories do
+ [
+ :channel_live,
+ :forum_post,
+ :forum_topic,
+ :gallery_image,
+ :image_comment,
+ :image_merge
+ ]
+ end
+
+ @doc """
+ Return the count of all currently unread notifications for the user in all categories.
+
+ ## Examples
+
+ iex> total_unread_notification_count(user)
+ 15
+
+ """
+ def total_unread_notification_count(user) do
+ categories()
+ |> Enum.map(fn category ->
+ category
+ |> query_for_category_and_user(user)
+ |> exclude(:preload)
+ |> select([_], %{one: 1})
+ end)
+ |> union_all_queries()
+ |> Repo.aggregate(:count)
+ end
+
+ defp union_all_queries([query | rest]) do
+ Enum.reduce(rest, query, fn q, acc -> union_all(acc, ^q) end)
+ end
+
+ @doc """
+ Gather up and return the top N notifications for the user, for each category of
+ unread notification currently existing.
+
+ ## Examples
+
+ iex> unread_notifications_for_user(user, page_size: 10)
+ [
+ channel_live: [],
+ forum_post: [%ForumPostNotification{...}, ...],
+ forum_topic: [%ForumTopicNotification{...}, ...],
+ gallery_image: [],
+ image_comment: [%ImageCommentNotification{...}, ...],
+ image_merge: []
+ ]
+
+ """
+ def unread_notifications_for_user(user, pagination) do
+ Enum.map(categories(), fn category ->
+ results =
+ category
+ |> query_for_category_and_user(user)
+ |> order_by(desc: :updated_at)
+ |> Repo.paginate(pagination)
+
+ {category, results}
+ end)
+ end
+
+ @doc """
+ Returns paginated unread notifications for the user, given the category.
+
+ ## Examples
+
+ iex> unread_notifications_for_user_and_category(user, :image_comment)
+ [%ImageCommentNotification{...}]
+
+ """
+ def unread_notifications_for_user_and_category(user, category, pagination) do
+ category
+ |> query_for_category_and_user(user)
+ |> order_by(desc: :updated_at)
+ |> Repo.paginate(pagination)
+ end
+
+ @doc """
+ Determine the category of a notification.
+
+ ## Examples
+
+ iex> notification_category(%ImageCommentNotification{})
+ :image_comment
+
+ """
+ def notification_category(n) do
+ case n.__struct__ do
+ ChannelLiveNotification -> :channel_live
+ GalleryImageNotification -> :gallery_image
+ ImageCommentNotification -> :image_comment
+ ImageMergeNotification -> :image_merge
+ ForumPostNotification -> :forum_post
+ ForumTopicNotification -> :forum_topic
+ end
+ end
+
+ @doc """
+ Returns an `m:Ecto.Query` that finds unread notifications for the given category,
+ for the given user, with preloads applied.
+
+ ## Examples
+
+ iex> query_for_category_and_user(:channel_live, user)
+ #Ecto.Query
+
+ """
+ def query_for_category_and_user(category, user) do
+ query =
+ case category do
+ :channel_live ->
+ from(n in ChannelLiveNotification, preload: :channel)
+
+ :gallery_image ->
+ from(n in GalleryImageNotification, preload: [gallery: :creator])
+
+ :image_comment ->
+ from(n in ImageCommentNotification,
+ preload: [image: [:sources, tags: :aliases], comment: :user]
+ )
+
+ :image_merge ->
+ from(n in ImageMergeNotification,
+ preload: [:source, target: [:sources, tags: :aliases]]
+ )
+
+ :forum_topic ->
+ from(n in ForumTopicNotification, preload: [topic: [:forum, :user]])
+
+ :forum_post ->
+ from(n in ForumPostNotification, preload: [topic: :forum, post: :user])
+ end
+
+ where(query, user_id: ^user.id)
+ end
+end
diff --git a/lib/philomena/notifications/channel_live_notification.ex b/lib/philomena/notifications/channel_live_notification.ex
new file mode 100644
index 00000000..109c784f
--- /dev/null
+++ b/lib/philomena/notifications/channel_live_notification.ex
@@ -0,0 +1,17 @@
+defmodule Philomena.Notifications.ChannelLiveNotification do
+ use Ecto.Schema
+
+ alias Philomena.Users.User
+ alias Philomena.Channels.Channel
+
+ @primary_key false
+
+ schema "channel_live_notifications" do
+ belongs_to :user, User, primary_key: true
+ belongs_to :channel, Channel, primary_key: true
+
+ field :read, :boolean, default: false
+
+ timestamps(inserted_at: :created_at, type: :utc_datetime)
+ end
+end
diff --git a/lib/philomena/notifications/creator.ex b/lib/philomena/notifications/creator.ex
new file mode 100644
index 00000000..808a6d3d
--- /dev/null
+++ b/lib/philomena/notifications/creator.ex
@@ -0,0 +1,92 @@
+defmodule Philomena.Notifications.Creator do
+ @moduledoc """
+ Internal notifications creation logic.
+ """
+
+ import Ecto.Query, warn: false
+ alias Philomena.Repo
+
+ @doc """
+ Propagate notifications for a notification table type.
+
+ Returns `{:ok, count}`, where `count` is the number of affected rows.
+
+ ## Examples
+
+ iex> broadcast_notification(
+ ...> from: {GallerySubscription, gallery_id: gallery.id},
+ ...> into: GalleryImageNotification,
+ ...> select: [gallery_id: gallery.id],
+ ...> unique_key: :gallery_id
+ ...> )
+ {:ok, 2}
+
+ iex> broadcast_notification(
+ ...> notification_author: user,
+ ...> from: {ImageSubscription, image_id: image.id},
+ ...> into: ImageCommentNotification,
+ ...> select: [image_id: image.id, comment_id: comment.id],
+ ...> unique_key: :image_id
+ ...> )
+ {:ok, 2}
+
+ """
+ def broadcast_notification(opts) do
+ opts = Keyword.validate!(opts, [:notification_author, :from, :into, :select, :unique_key])
+
+ notification_author = Keyword.get(opts, :notification_author, nil)
+ {subscription_schema, filters} = Keyword.fetch!(opts, :from)
+ notification_schema = Keyword.fetch!(opts, :into)
+ select_keywords = Keyword.fetch!(opts, :select)
+ unique_key = Keyword.fetch!(opts, :unique_key)
+
+ subscription_schema
+ |> subscription_query(notification_author)
+ |> where(^filters)
+ |> convert_to_notification(select_keywords)
+ |> insert_notifications(notification_schema, unique_key)
+ end
+
+ defp convert_to_notification(subscription, extra) do
+ now = dynamic([_], type(^DateTime.utc_now(:second), :utc_datetime))
+
+ base = %{
+ user_id: dynamic([s], s.user_id),
+ created_at: now,
+ updated_at: now,
+ read: false
+ }
+
+ extra =
+ Map.new(extra, fn {field, value} ->
+ {field, dynamic([_], type(^value, :integer))}
+ end)
+
+ from(subscription, select: ^Map.merge(base, extra))
+ end
+
+ defp subscription_query(subscription, notification_author) do
+ case notification_author do
+ %{id: user_id} ->
+ # Avoid sending notifications to the user which performed the action.
+ from s in subscription,
+ where: s.user_id != ^user_id
+
+ _ ->
+ # When not created by a user, send notifications to all subscribers.
+ subscription
+ end
+ end
+
+ defp insert_notifications(query, notification, unique_key) do
+ {count, nil} =
+ Repo.insert_all(
+ notification,
+ query,
+ on_conflict: {:replace_all_except, [:created_at]},
+ conflict_target: [unique_key, :user_id]
+ )
+
+ {:ok, count}
+ end
+end
diff --git a/lib/philomena/notifications/forum_post_notification.ex b/lib/philomena/notifications/forum_post_notification.ex
new file mode 100644
index 00000000..f0313628
--- /dev/null
+++ b/lib/philomena/notifications/forum_post_notification.ex
@@ -0,0 +1,19 @@
+defmodule Philomena.Notifications.ForumPostNotification do
+ use Ecto.Schema
+
+ alias Philomena.Users.User
+ alias Philomena.Topics.Topic
+ alias Philomena.Posts.Post
+
+ @primary_key false
+
+ schema "forum_post_notifications" do
+ belongs_to :user, User, primary_key: true
+ belongs_to :topic, Topic, primary_key: true
+ belongs_to :post, Post
+
+ field :read, :boolean, default: false
+
+ timestamps(inserted_at: :created_at, type: :utc_datetime)
+ end
+end
diff --git a/lib/philomena/notifications/forum_topic_notification.ex b/lib/philomena/notifications/forum_topic_notification.ex
new file mode 100644
index 00000000..2ff39d38
--- /dev/null
+++ b/lib/philomena/notifications/forum_topic_notification.ex
@@ -0,0 +1,17 @@
+defmodule Philomena.Notifications.ForumTopicNotification do
+ use Ecto.Schema
+
+ alias Philomena.Users.User
+ alias Philomena.Topics.Topic
+
+ @primary_key false
+
+ schema "forum_topic_notifications" do
+ belongs_to :user, User, primary_key: true
+ belongs_to :topic, Topic, primary_key: true
+
+ field :read, :boolean, default: false
+
+ timestamps(inserted_at: :created_at, type: :utc_datetime)
+ end
+end
diff --git a/lib/philomena/notifications/gallery_image_notification.ex b/lib/philomena/notifications/gallery_image_notification.ex
new file mode 100644
index 00000000..816de3ed
--- /dev/null
+++ b/lib/philomena/notifications/gallery_image_notification.ex
@@ -0,0 +1,17 @@
+defmodule Philomena.Notifications.GalleryImageNotification do
+ use Ecto.Schema
+
+ alias Philomena.Users.User
+ alias Philomena.Galleries.Gallery
+
+ @primary_key false
+
+ schema "gallery_image_notifications" do
+ belongs_to :user, User, primary_key: true
+ belongs_to :gallery, Gallery, primary_key: true
+
+ field :read, :boolean, default: false
+
+ timestamps(inserted_at: :created_at, type: :utc_datetime)
+ end
+end
diff --git a/lib/philomena/notifications/image_comment_notification.ex b/lib/philomena/notifications/image_comment_notification.ex
new file mode 100644
index 00000000..28487d9d
--- /dev/null
+++ b/lib/philomena/notifications/image_comment_notification.ex
@@ -0,0 +1,19 @@
+defmodule Philomena.Notifications.ImageCommentNotification do
+ use Ecto.Schema
+
+ alias Philomena.Users.User
+ alias Philomena.Images.Image
+ alias Philomena.Comments.Comment
+
+ @primary_key false
+
+ schema "image_comment_notifications" do
+ belongs_to :user, User, primary_key: true
+ belongs_to :image, Image, primary_key: true
+ belongs_to :comment, Comment
+
+ field :read, :boolean, default: false
+
+ timestamps(inserted_at: :created_at, type: :utc_datetime)
+ end
+end
diff --git a/lib/philomena/notifications/image_merge_notification.ex b/lib/philomena/notifications/image_merge_notification.ex
new file mode 100644
index 00000000..e767ffbd
--- /dev/null
+++ b/lib/philomena/notifications/image_merge_notification.ex
@@ -0,0 +1,18 @@
+defmodule Philomena.Notifications.ImageMergeNotification do
+ use Ecto.Schema
+
+ alias Philomena.Users.User
+ alias Philomena.Images.Image
+
+ @primary_key false
+
+ schema "image_merge_notifications" do
+ belongs_to :user, User, primary_key: true
+ belongs_to :target, Image, primary_key: true
+ belongs_to :source, Image
+
+ field :read, :boolean, default: false
+
+ timestamps(inserted_at: :created_at, type: :utc_datetime)
+ end
+end
diff --git a/lib/philomena/notifications/notification.ex b/lib/philomena/notifications/notification.ex
deleted file mode 100644
index 72951bbe..00000000
--- a/lib/philomena/notifications/notification.ex
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule Philomena.Notifications.Notification do
- use Ecto.Schema
- import Ecto.Changeset
-
- schema "notifications" do
- field :action, :string
-
- # fixme: rails polymorphic relation
- field :actor_id, :integer
- field :actor_type, :string
- field :actor_child_id, :integer
- field :actor_child_type, :string
-
- field :actor, :any, virtual: true
- field :actor_child, :any, virtual: true
-
- timestamps(inserted_at: :created_at, type: :utc_datetime)
- end
-
- @doc false
- def changeset(notification, attrs) do
- notification
- |> cast(attrs, [:actor_id, :actor_type, :actor_child_id, :actor_child_type, :action])
- |> validate_required([:actor_id, :actor_type, :action])
- end
-end
diff --git a/lib/philomena/notifications/unread_notification.ex b/lib/philomena/notifications/unread_notification.ex
deleted file mode 100644
index 1d111141..00000000
--- a/lib/philomena/notifications/unread_notification.ex
+++ /dev/null
@@ -1,21 +0,0 @@
-defmodule Philomena.Notifications.UnreadNotification do
- use Ecto.Schema
- import Ecto.Changeset
-
- alias Philomena.Users.User
- alias Philomena.Notifications.Notification
-
- @primary_key false
-
- schema "unread_notifications" do
- belongs_to :user, User, primary_key: true
- belongs_to :notification, Notification, primary_key: true
- end
-
- @doc false
- def changeset(unread_notification, attrs) do
- unread_notification
- |> cast(attrs, [])
- |> validate_required([])
- end
-end
diff --git a/lib/philomena/poll_votes.ex b/lib/philomena/poll_votes.ex
index 910741c1..5e23f181 100644
--- a/lib/philomena/poll_votes.ex
+++ b/lib/philomena/poll_votes.ex
@@ -41,7 +41,7 @@ defmodule Philomena.PollVotes do
"""
def create_poll_votes(user, poll, attrs) do
- now = DateTime.utc_now() |> DateTime.truncate(:second)
+ now = DateTime.utc_now(:second)
poll_votes = filter_options(user, poll, now, attrs)
Multi.new()
diff --git a/lib/philomena/polls.ex b/lib/philomena/polls.ex
index 5dce91be..61cfaaa7 100644
--- a/lib/philomena/polls.ex
+++ b/lib/philomena/polls.ex
@@ -51,7 +51,7 @@ defmodule Philomena.Polls do
"""
def create_poll(attrs \\ %{}) do
%Poll{}
- |> Poll.update_changeset(attrs)
+ |> Poll.changeset(attrs)
|> Repo.insert()
end
@@ -69,7 +69,7 @@ defmodule Philomena.Polls do
"""
def update_poll(%Poll{} = poll, attrs) do
poll
- |> Poll.update_changeset(attrs)
+ |> Poll.changeset(attrs)
|> Repo.update()
end
diff --git a/lib/philomena/polls/poll.ex b/lib/philomena/polls/poll.ex
index 919a62a1..eb998265 100644
--- a/lib/philomena/polls/poll.ex
+++ b/lib/philomena/polls/poll.ex
@@ -3,22 +3,16 @@ defmodule Philomena.Polls.Poll do
import Ecto.Changeset
alias Philomena.Topics.Topic
- alias Philomena.Users.User
alias Philomena.PollOptions.PollOption
- alias Philomena.Schema.Time
schema "polls" do
belongs_to :topic, Topic
- belongs_to :deleted_by, User
has_many :options, PollOption
field :title, :string
field :vote_method, :string
- field :active_until, :utc_datetime
+ field :active_until, PhilomenaQuery.Ecto.RelativeDate
field :total_votes, :integer, default: 0
- field :hidden_from_users, :boolean, default: false
- field :deletion_reason, :string, default: ""
- field :until, :string, virtual: true
timestamps(inserted_at: :created_at, type: :utc_datetime)
end
@@ -26,16 +20,7 @@ defmodule Philomena.Polls.Poll do
@doc false
def changeset(poll, attrs) do
poll
- |> cast(attrs, [])
- |> validate_required([])
- |> Time.propagate_time(:active_until, :until)
- end
-
- @doc false
- def update_changeset(poll, attrs) do
- poll
- |> cast(attrs, [:title, :until, :vote_method])
- |> Time.assign_time(:until, :active_until)
+ |> cast(attrs, [:title, :active_until, :vote_method])
|> validate_required([:title, :active_until, :vote_method])
|> validate_length(:title, max: 140, count: :bytes)
|> validate_inclusion(:vote_method, ["single", "multiple"])
diff --git a/lib/philomena/posts.ex b/lib/philomena/posts.ex
index 16795e6c..ff5a405f 100644
--- a/lib/philomena/posts.ex
+++ b/lib/philomena/posts.ex
@@ -16,11 +16,8 @@ defmodule Philomena.Posts do
alias Philomena.IndexWorker
alias Philomena.Forums.Forum
alias Philomena.Notifications
- alias Philomena.NotificationWorker
alias Philomena.Versions
alias Philomena.Reports
- alias Philomena.Reports.Report
- alias Philomena.Users.User
@doc """
Gets a single post.
@@ -51,7 +48,7 @@ defmodule Philomena.Posts do
"""
def create_post(topic, attributes, params \\ %{}) do
- now = DateTime.utc_now()
+ now = DateTime.utc_now(:second)
topic_query =
Topic
@@ -66,7 +63,7 @@ defmodule Philomena.Posts do
|> where(id: ^topic.forum_id)
Multi.new()
- |> Multi.all(:topic_lock, topic_lock_query)
+ |> Multi.one(:topic, topic_lock_query)
|> Multi.run(:post, fn repo, _ ->
last_position =
Post
@@ -95,7 +92,8 @@ defmodule Philomena.Posts do
{:ok, count}
end)
- |> maybe_create_subscription_on_reply(topic, attributes[:user])
+ |> Multi.run(:notification, ¬ify_post/2)
+ |> Topics.maybe_subscribe_on(:topic, attributes[:user], :watch_on_reply)
|> Repo.transaction()
|> case do
{:ok, %{post: post}} = result ->
@@ -108,58 +106,20 @@ defmodule Philomena.Posts do
end
end
- defp maybe_create_subscription_on_reply(multi, topic, %User{watch_on_reply: true} = user) do
- multi
- |> Multi.run(:subscribe, fn _repo, _changes ->
- Topics.create_subscription(topic, user)
- end)
- end
-
- defp maybe_create_subscription_on_reply(multi, _topic, _user) do
- multi
- end
-
- def notify_post(post) do
- Exq.enqueue(Exq, "notifications", NotificationWorker, ["Posts", post.id])
+ defp notify_post(_repo, %{post: post, topic: topic}) do
+ Notifications.create_forum_post_notification(post.user, topic, post)
end
def report_non_approved(%Post{approved: true}), do: false
def report_non_approved(post) do
Reports.create_system_report(
- post.id,
- "Post",
+ {"Post", post.id},
"Approval",
"Post contains externally-embedded images and has been flagged for review."
)
end
- def perform_notify(post_id) do
- post = get_post!(post_id)
-
- topic =
- post
- |> Repo.preload(:topic)
- |> Map.fetch!(:topic)
-
- subscriptions =
- topic
- |> Repo.preload(:subscriptions)
- |> Map.fetch!(:subscriptions)
-
- Notifications.notify(
- post,
- subscriptions,
- %{
- actor_id: topic.id,
- actor_type: "Topic",
- actor_child_id: post.id,
- actor_child_type: "Post",
- action: "posted a new reply in"
- }
- )
- end
-
@doc """
Updates a post.
@@ -173,7 +133,7 @@ defmodule Philomena.Posts do
"""
def update_post(%Post{} = post, editor, attrs) do
- now = DateTime.utc_now() |> DateTime.truncate(:second)
+ now = DateTime.utc_now(:second)
current_body = post.body
current_reason = post.edit_reason
@@ -216,11 +176,7 @@ defmodule Philomena.Posts do
end
def hide_post(%Post{} = post, attrs, user) do
- reports =
- Report
- |> where(reportable_type: "Post", reportable_id: ^post.id)
- |> select([r], r.id)
- |> update(set: [open: false, state: "closed", admin_id: ^user.id])
+ report_query = Reports.close_report_query({"Post", post.id}, user)
topics =
Topic
@@ -236,7 +192,7 @@ defmodule Philomena.Posts do
Multi.new()
|> Multi.update(:post, post)
- |> Multi.update_all(:reports, reports, [])
+ |> Multi.update_all(:reports, report_query, [])
|> Multi.update_all(:topics, topics, [])
|> Multi.update_all(:forums, forums, [])
|> Repo.transaction()
@@ -267,21 +223,15 @@ defmodule Philomena.Posts do
end
def approve_post(%Post{} = post, user) do
- reports =
- Report
- |> where(reportable_type: "Post", reportable_id: ^post.id)
- |> select([r], r.id)
- |> update(set: [open: false, state: "closed", admin_id: ^user.id])
-
+ report_query = Reports.close_report_query({"Post", post.id}, user)
post = Post.approve_changeset(post)
Multi.new()
|> Multi.update(:post, post)
- |> Multi.update_all(:reports, reports, [])
+ |> Multi.update_all(:reports, report_query, [])
|> Repo.transaction()
|> case do
{:ok, %{post: post, reports: {_count, reports}}} ->
- notify_post(post)
UserStatistics.inc_stat(post.user, :forum_posts)
Reports.reindex_reports(reports)
reindex_post(post)
diff --git a/lib/philomena/posts/post.ex b/lib/philomena/posts/post.ex
index 55d5b401..11fc87bf 100644
--- a/lib/philomena/posts/post.ex
+++ b/lib/philomena/posts/post.ex
@@ -15,15 +15,12 @@ defmodule Philomena.Posts.Post do
field :edit_reason, :string
field :ip, EctoNetwork.INET
field :fingerprint, :string
- field :user_agent, :string, default: ""
- field :referrer, :string, default: ""
field :topic_position, :integer
field :hidden_from_users, :boolean, default: false
field :anonymous, :boolean, default: false
field :edited_at, :utc_datetime
field :deletion_reason, :string, default: ""
field :destroyed_content, :boolean, default: false
- field :name_at_post_time, :string
field :approved, :boolean, default: false
timestamps(inserted_at: :created_at, type: :utc_datetime)
@@ -47,7 +44,6 @@ defmodule Philomena.Posts.Post do
|> validate_required([:body])
|> validate_length(:body, min: 1, max: 300_000, count: :bytes)
|> change(attribution)
- |> put_name_at_post_time(attribution[:user])
|> Approval.maybe_put_approval(attribution[:user])
|> Approval.maybe_strip_images(attribution[:user])
end
@@ -61,7 +57,6 @@ defmodule Philomena.Posts.Post do
|> validate_length(:body, min: 1, max: 300_000, count: :bytes)
|> change(attribution)
|> change(topic_position: 0)
- |> put_name_at_post_time(attribution[:user])
|> Approval.maybe_put_approval(attribution[:user])
|> Approval.maybe_strip_images(attribution[:user])
end
@@ -90,7 +85,4 @@ defmodule Philomena.Posts.Post do
change(post)
|> put_change(:approved, true)
end
-
- defp put_name_at_post_time(changeset, nil), do: changeset
- defp put_name_at_post_time(changeset, user), do: change(changeset, name_at_post_time: user.name)
end
diff --git a/lib/philomena/posts/query.ex b/lib/philomena/posts/query.ex
index 331655c7..58d94d6c 100644
--- a/lib/philomena/posts/query.ex
+++ b/lib/philomena/posts/query.ex
@@ -90,8 +90,8 @@ defmodule Philomena.Posts.Query do
|> Parser.parse(query_string, context)
end
- def compile(user, query_string) do
- query_string = query_string || ""
+ def compile(query_string, opts \\ []) do
+ user = Keyword.get(opts, :user)
case user do
nil ->
diff --git a/lib/philomena/posts/search_index.ex b/lib/philomena/posts/search_index.ex
index 9c8c2780..b5522fa1 100644
--- a/lib/philomena/posts/search_index.ex
+++ b/lib/philomena/posts/search_index.ex
@@ -52,8 +52,8 @@ defmodule Philomena.Posts.SearchIndex do
author: if(!!post.user and !post.anonymous, do: String.downcase(post.user.name)),
subject: post.topic.title,
ip: post.ip |> to_string(),
- user_agent: post.user_agent,
- referrer: post.referrer,
+ user_agent: "",
+ referrer: "",
fingerprint: post.fingerprint,
topic_position: post.topic_position,
forum: post.topic.forum.short_name,
diff --git a/lib/philomena/reports.ex b/lib/philomena/reports.ex
index 1639929d..7e0466dc 100644
--- a/lib/philomena/reports.ex
+++ b/lib/philomena/reports.ex
@@ -12,6 +12,31 @@ defmodule Philomena.Reports do
alias Philomena.IndexWorker
alias Philomena.Polymorphic
+ @doc """
+ Returns the current number of open reports.
+
+ If the user is allowed to view reports, returns the current count.
+ If the user is not allowed to view reports, returns `nil`.
+
+ ## Examples
+
+ iex> count_reports(%User{})
+ nil
+
+ iex> count_reports(%User{role: "admin"})
+ 4
+
+ """
+ def count_open_reports(user) do
+ if Canada.Can.can?(user, :index, Report) do
+ Report
+ |> where(open: true)
+ |> Repo.aggregate(:count)
+ else
+ nil
+ end
+ end
+
@doc """
Returns the list of reports.
@@ -53,14 +78,59 @@ defmodule Philomena.Reports do
{:error, %Ecto.Changeset{}}
"""
- def create_report(reportable_id, reportable_type, attribution, attrs \\ %{}) do
- %Report{reportable_id: reportable_id, reportable_type: reportable_type}
+ def create_report({reportable_type, reportable_id} = _type_and_id, attribution, attrs \\ %{}) do
+ %Report{reportable_type: reportable_type, reportable_id: reportable_id}
|> Report.creation_changeset(attrs, attribution)
|> Repo.insert()
|> reindex_after_update()
end
- def create_system_report(reportable_id, reportable_type, category, reason) do
+ @doc """
+ Returns an `m:Ecto.Query` which updates all reports for the given `reportable_type`
+ and `reportable_id` to close them.
+
+ Because this is only a query due to the limitations of `m:Ecto.Multi`, this must be
+ coupled with an associated call to `reindex_reports/1` to operate correctly, e.g.:
+
+ report_query = Reports.close_system_report_query({"Image", image.id}, user)
+
+ Multi.new()
+ |> Multi.update_all(:reports, report_query, [])
+ |> Repo.transaction()
+ |> case do
+ {:ok, %{reports: {_count, reports}} = result} ->
+ Reports.reindex_reports(reports)
+
+ {:ok, result}
+
+ error ->
+ error
+ end
+
+ ## Examples
+
+ iex> close_system_report_query("Image", 1, %User{})
+ #Ecto.Query<...>
+
+ """
+ def close_report_query({reportable_type, reportable_id} = _type_and_id, closing_user) do
+ from r in Report,
+ where: r.reportable_type == ^reportable_type and r.reportable_id == ^reportable_id,
+ select: r.id,
+ update: [set: [open: false, state: "closed", admin_id: ^closing_user.id]]
+ end
+
+ @doc """
+ Automatically create a report with the given category and reason on the given
+ `reportable_id` and `reportable_type`.
+
+ ## Examples
+
+ iex> create_system_report({"Comment", 1}, "Other", "Custom report reason")
+ {:ok, %Report{}}
+
+ """
+ def create_system_report({reportable_type, reportable_id} = _type_and_id, category, reason) do
attrs = %{
reason: reason,
category: category
@@ -69,12 +139,10 @@ defmodule Philomena.Reports do
attributes = %{
system: true,
ip: %Postgrex.INET{address: {127, 0, 0, 1}, netmask: 32},
- fingerprint: "ffff",
- user_agent:
- "Mozilla/5.0 (X11; Philomena; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0"
+ fingerprint: "ffff"
}
- %Report{reportable_id: reportable_id, reportable_type: reportable_type}
+ %Report{reportable_type: reportable_type, reportable_id: reportable_id}
|> Report.creation_changeset(attrs, attributes)
|> Repo.insert()
|> reindex_after_update()
@@ -128,6 +196,15 @@ defmodule Philomena.Reports do
Report.changeset(report, %{})
end
+ @doc """
+ Marks the report as claimed by the given user.
+
+ ## Example
+
+ iex> claim_report(%Report{}, %User{})
+ {:ok, %Report{}}
+
+ """
def claim_report(%Report{} = report, user) do
report
|> Report.claim_changeset(user)
@@ -135,6 +212,15 @@ defmodule Philomena.Reports do
|> reindex_after_update()
end
+ @doc """
+ Marks the report as unclaimed.
+
+ ## Example
+
+ iex> unclaim_report(%Report{})
+ {:ok, %Report{}}
+
+ """
def unclaim_report(%Report{} = report) do
report
|> Report.unclaim_changeset()
@@ -142,6 +228,15 @@ defmodule Philomena.Reports do
|> reindex_after_update()
end
+ @doc """
+ Marks the report as closed by the given user.
+
+ ## Example
+
+ iex> close_report(%Report{}, %User{})
+ {:ok, %Report{}}
+
+ """
def close_report(%Report{} = report, user) do
report
|> Report.close_changeset(user)
@@ -149,6 +244,15 @@ defmodule Philomena.Reports do
|> reindex_after_update()
end
+ @doc """
+ Reindex all reports where the user or admin has `old_name`.
+
+ ## Example
+
+ iex> user_name_reindex("Administrator", "Administrator2")
+ {:ok, %Req.Response{}}
+
+ """
def user_name_reindex(old_name, new_name) do
data = ReportIndex.user_name_update_by_query(old_name, new_name)
@@ -165,18 +269,25 @@ defmodule Philomena.Reports do
result
end
+ @doc """
+ Callback for post-transaction update.
+
+ See `close_report_query/2` for more information and example.
+ """
def reindex_reports(report_ids) do
Exq.enqueue(Exq, "indexing", IndexWorker, ["Reports", "id", report_ids])
report_ids
end
+ @doc false
def reindex_report(%Report{} = report) do
Exq.enqueue(Exq, "indexing", IndexWorker, ["Reports", "id", [report.id]])
report
end
+ @doc false
def perform_reindex(column, condition) do
Report
|> where([r], field(r, ^column) in ^condition)
@@ -185,14 +296,4 @@ defmodule Philomena.Reports do
|> Polymorphic.load_polymorphic(reportable: [reportable_id: :reportable_type])
|> Enum.map(&Search.index_document(&1, Report))
end
-
- def count_reports(user) do
- if Canada.Can.can?(user, :index, Report) do
- Report
- |> where(open: true)
- |> Repo.aggregate(:count, :id)
- else
- nil
- end
- end
end
diff --git a/lib/philomena/reports/query.ex b/lib/philomena/reports/query.ex
index c9d9be44..e88e2172 100644
--- a/lib/philomena/reports/query.ex
+++ b/lib/philomena/reports/query.ex
@@ -17,6 +17,6 @@ defmodule Philomena.Reports.Query do
def compile(query_string) do
fields()
|> Parser.new()
- |> Parser.parse(query_string || "", %{})
+ |> Parser.parse(query_string, %{})
end
end
diff --git a/lib/philomena/reports/report.ex b/lib/philomena/reports/report.ex
index 461b6eea..a17b5a34 100644
--- a/lib/philomena/reports/report.ex
+++ b/lib/philomena/reports/report.ex
@@ -11,7 +11,6 @@ defmodule Philomena.Reports.Report do
field :ip, EctoNetwork.INET
field :fingerprint, :string
field :user_agent, :string, default: ""
- field :referrer, :string, default: ""
field :reason, :string
field :state, :string, default: "open"
field :open, :boolean, default: true
@@ -61,8 +60,9 @@ defmodule Philomena.Reports.Report do
@doc false
def creation_changeset(report, attrs, attribution) do
report
- |> cast(attrs, [:category, :reason])
+ |> cast(attrs, [:category, :reason, :user_agent])
|> validate_length(:reason, max: 10_000, count: :bytes)
+ |> validate_length(:user_agent, max: 1000, count: :bytes)
|> merge_category()
|> change(attribution)
|> validate_required([
diff --git a/lib/philomena/roles/role.ex b/lib/philomena/roles/role.ex
index 359c90b1..27ccb73a 100644
--- a/lib/philomena/roles/role.ex
+++ b/lib/philomena/roles/role.ex
@@ -4,12 +4,7 @@ defmodule Philomena.Roles.Role do
schema "roles" do
field :name, :string
-
- # fixme: rails polymorphic relation
- field :resource_id, :integer
field :resource_type, :string
-
- timestamps(inserted_at: :created_at, type: :utc_datetime)
end
@doc false
diff --git a/lib/philomena/schema/approval.ex b/lib/philomena/schema/approval.ex
index 512c5aab..f78144f9 100644
--- a/lib/philomena/schema/approval.ex
+++ b/lib/philomena/schema/approval.ex
@@ -15,7 +15,7 @@ defmodule Philomena.Schema.Approval do
%{changes: %{body: body}, valid?: true} = changeset,
%User{} = user
) do
- now = now_time()
+ now = DateTime.utc_now(:second)
# 14 * 24 * 60 * 60
two_weeks = 1_209_600
@@ -40,6 +40,4 @@ defmodule Philomena.Schema.Approval do
do: change(changeset, body: Regex.replace(@image_embed_regex, body, "["))
def maybe_strip_images(changeset, _user), do: changeset
-
- defp now_time(), do: DateTime.truncate(DateTime.utc_now(), :second)
end
diff --git a/lib/philomena/schema/search.ex b/lib/philomena/schema/search.ex
deleted file mode 100644
index 9b4e7e08..00000000
--- a/lib/philomena/schema/search.ex
+++ /dev/null
@@ -1,18 +0,0 @@
-defmodule Philomena.Schema.Search do
- alias Philomena.Images.Query
- alias PhilomenaQuery.Parse.String
- import Ecto.Changeset
-
- def validate_search(changeset, field, user, watched \\ false) do
- query = changeset |> get_field(field) |> String.normalize()
- output = Query.compile(user, query, watched)
-
- case output do
- {:ok, _} ->
- changeset
-
- _ ->
- add_error(changeset, field, "is invalid")
- end
- end
-end
diff --git a/lib/philomena/schema/time.ex b/lib/philomena/schema/time.ex
deleted file mode 100644
index fff11419..00000000
--- a/lib/philomena/schema/time.ex
+++ /dev/null
@@ -1,23 +0,0 @@
-defmodule Philomena.Schema.Time do
- alias PhilomenaQuery.RelativeDate
- import Ecto.Changeset
-
- def assign_time(changeset, field, target_field) do
- changeset
- |> get_field(field)
- |> RelativeDate.parse()
- |> case do
- {:ok, time} ->
- put_change(changeset, target_field, time)
-
- _err ->
- add_error(changeset, field, "is not a valid relative or absolute date and time")
- end
- end
-
- def propagate_time(changeset, field, target_field) do
- time = get_field(changeset, field)
-
- put_change(changeset, target_field, to_string(time))
- end
-end
diff --git a/lib/philomena/site_notices.ex b/lib/philomena/site_notices.ex
index b38f1fa4..a9042614 100644
--- a/lib/philomena/site_notices.ex
+++ b/lib/philomena/site_notices.ex
@@ -57,7 +57,7 @@ defmodule Philomena.SiteNotices do
"""
def create_site_notice(creator, attrs \\ %{}) do
%SiteNotice{user_id: creator.id}
- |> SiteNotice.save_changeset(attrs)
+ |> SiteNotice.changeset(attrs)
|> Repo.insert()
end
@@ -75,7 +75,7 @@ defmodule Philomena.SiteNotices do
"""
def update_site_notice(%SiteNotice{} = site_notice, attrs) do
site_notice
- |> SiteNotice.save_changeset(attrs)
+ |> SiteNotice.changeset(attrs)
|> Repo.update()
end
diff --git a/lib/philomena/site_notices/site_notice.ex b/lib/philomena/site_notices/site_notice.ex
index 929f8f3c..fa76558a 100644
--- a/lib/philomena/site_notices/site_notice.ex
+++ b/lib/philomena/site_notices/site_notice.ex
@@ -3,21 +3,17 @@ defmodule Philomena.SiteNotices.SiteNotice do
import Ecto.Changeset
alias Philomena.Users.User
- alias Philomena.Schema.Time
schema "site_notices" do
belongs_to :user, User
field :title, :string
- field :text, :string, default: ""
+ field :text, :string
field :link, :string, default: ""
field :link_text, :string, default: ""
field :live, :boolean, default: true
- field :start_date, :utc_datetime
- field :finish_date, :utc_datetime
-
- field :start_time, :string, virtual: true
- field :finish_time, :string, virtual: true
+ field :start_date, PhilomenaQuery.Ecto.RelativeDate
+ field :finish_date, PhilomenaQuery.Ecto.RelativeDate
timestamps(inserted_at: :created_at, type: :utc_datetime)
end
@@ -25,16 +21,7 @@ defmodule Philomena.SiteNotices.SiteNotice do
@doc false
def changeset(site_notice, attrs) do
site_notice
- |> cast(attrs, [])
- |> Time.propagate_time(:start_date, :start_time)
- |> Time.propagate_time(:finish_date, :finish_time)
- |> validate_required([])
- end
-
- def save_changeset(site_notice, attrs) do
- site_notice
- |> cast(attrs, [:title, :text, :link, :link_text, :live, :start_time, :finish_time])
- |> Time.assign_time(:start_time, :start_date)
- |> Time.assign_time(:finish_time, :finish_date)
+ |> cast(attrs, [:title, :text, :link, :link_text, :live, :start_date, :finish_date])
+ |> validate_required([:title, :text, :live, :start_date, :finish_date])
end
end
diff --git a/lib/philomena/source_changes/source_change.ex b/lib/philomena/source_changes/source_change.ex
index 3cff4685..d17e0d93 100644
--- a/lib/philomena/source_changes/source_change.ex
+++ b/lib/philomena/source_changes/source_change.ex
@@ -8,8 +8,6 @@ defmodule Philomena.SourceChanges.SourceChange do
field :ip, EctoNetwork.INET
field :fingerprint, :string
- field :user_agent, :string, default: ""
- field :referrer, :string, default: ""
field :value, :string
field :added, :boolean
diff --git a/lib/philomena/subscriptions.ex b/lib/philomena/subscriptions.ex
new file mode 100644
index 00000000..d0688107
--- /dev/null
+++ b/lib/philomena/subscriptions.ex
@@ -0,0 +1,202 @@
+defmodule Philomena.Subscriptions do
+ @moduledoc """
+ Common subscription logic.
+
+ `use Philomena.Subscriptions` requires the following option:
+
+ - `:id_name`
+ This is the name of the object field in the subscription table.
+ For `m:Philomena.Images`, this would be `:image_id`.
+
+ The following functions and documentation are produced in the calling module:
+ - `subscribed?/2`
+ - `subscriptions/2`
+ - `create_subscription/2`
+ - `delete_subscription/2`
+ - `maybe_subscribe_on/4`
+ """
+
+ import Ecto.Query, warn: false
+ alias Ecto.Multi
+
+ alias Philomena.Repo
+
+ defmacro __using__(opts) do
+ # For Philomena.Images, this yields :image_id
+ field_name = Keyword.fetch!(opts, :id_name)
+
+ # Deletion callback
+ on_delete =
+ case Keyword.get(opts, :on_delete) do
+ nil ->
+ []
+
+ callback when is_atom(callback) ->
+ quote do
+ apply(__MODULE__, unquote(callback), [object, user])
+ end
+ end
+
+ # For Philomena.Images, this yields Philomena.Images.Subscription
+ subscription_module = Module.concat(__CALLER__.module, Subscription)
+
+ quote do
+ @doc """
+ Returns whether the user is currently subscribed to this object.
+
+ ## Examples
+
+ iex> subscribed?(object, user)
+ false
+
+ """
+ def subscribed?(object, user) do
+ Philomena.Subscriptions.subscribed?(
+ unquote(subscription_module),
+ unquote(field_name),
+ object,
+ user
+ )
+ end
+
+ @doc """
+ Returns a map containing whether the user is currently subscribed to any of
+ the provided objects.
+
+ ## Examples
+
+ iex> subscriptions([%{id: 1}, %{id: 2}], user)
+ %{2 => true}
+
+ """
+ def subscriptions(objects, user) do
+ Philomena.Subscriptions.subscriptions(
+ unquote(subscription_module),
+ unquote(field_name),
+ objects,
+ user
+ )
+ end
+
+ @doc """
+ Creates a subscription.
+
+ ## Examples
+
+ iex> create_subscription(object, user)
+ {:ok, %Subscription{}}
+
+ iex> create_subscription(object, user)
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def create_subscription(object, user) do
+ Philomena.Subscriptions.create_subscription(
+ unquote(subscription_module),
+ unquote(field_name),
+ object,
+ user
+ )
+ end
+
+ @doc """
+ Deletes a subscription and removes notifications for it.
+
+ ## Examples
+
+ iex> delete_subscription(object, user)
+ {:ok, %Subscription{}}
+
+ iex> delete_subscription(object, user)
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def delete_subscription(object, user) do
+ unquote(on_delete)
+
+ Philomena.Subscriptions.delete_subscription(
+ unquote(subscription_module),
+ unquote(field_name),
+ object,
+ user
+ )
+ end
+
+ @doc """
+ Creates a subscription inside the `m:Ecto.Multi` flow if `user` is not nil
+ and `field` in `user` is `true`.
+
+ Valid values for field are `:watch_on_reply`, `:watch_on_upload`, `:watch_on_new_topic`.
+
+ ## Examples
+
+ iex> maybe_subscribe_on(multi, :image, user, :watch_on_reply)
+ %Ecto.Multi{}
+
+ iex> maybe_subscribe_on(multi, :topic, nil, :watch_on_reply)
+ %Ecto.Multi{}
+
+ """
+ def maybe_subscribe_on(multi, change_name, user, field) do
+ Philomena.Subscriptions.maybe_subscribe_on(multi, __MODULE__, change_name, user, field)
+ end
+ end
+ end
+
+ @doc false
+ def subscribed?(subscription_module, field_name, object, user) do
+ case user do
+ nil ->
+ false
+
+ _ ->
+ subscription_module
+ |> where([s], field(s, ^field_name) == ^object.id and s.user_id == ^user.id)
+ |> Repo.exists?()
+ end
+ end
+
+ @doc false
+ def subscriptions(subscription_module, field_name, objects, user) do
+ case user do
+ nil ->
+ %{}
+
+ _ ->
+ object_ids = Enum.map(objects, & &1.id)
+
+ subscription_module
+ |> where([s], field(s, ^field_name) in ^object_ids and s.user_id == ^user.id)
+ |> Repo.all()
+ |> Map.new(&{Map.fetch!(&1, field_name), true})
+ end
+ end
+
+ @doc false
+ def create_subscription(subscription_module, field_name, object, user) do
+ struct!(subscription_module, [{field_name, object.id}, {:user_id, user.id}])
+ |> subscription_module.changeset(%{})
+ |> Repo.insert(on_conflict: :nothing)
+ end
+
+ @doc false
+ def delete_subscription(subscription_module, field_name, object, user) do
+ struct!(subscription_module, [{field_name, object.id}, {:user_id, user.id}])
+ |> Repo.delete()
+ end
+
+ @doc false
+ def maybe_subscribe_on(multi, module, change_name, user, field)
+ when field in [:watch_on_reply, :watch_on_upload, :watch_on_new_topic] do
+ case user do
+ %{^field => true} ->
+ Multi.run(multi, :subscribe, fn _repo, changes ->
+ object = Map.fetch!(changes, change_name)
+ module.create_subscription(object, user)
+ end)
+
+ _ ->
+ multi
+ end
+ end
+end
diff --git a/lib/philomena/tag_changes.ex b/lib/philomena/tag_changes.ex
index e92c35a3..2311088f 100644
--- a/lib/philomena/tag_changes.ex
+++ b/lib/philomena/tag_changes.ex
@@ -15,7 +15,7 @@ defmodule Philomena.TagChanges do
# TODO: this is substantially similar to Images.batch_update/4.
# Perhaps it should be extracted.
def mass_revert(ids, attributes) do
- now = DateTime.utc_now() |> DateTime.truncate(:second)
+ now = DateTime.utc_now(:second)
tag_change_attributes = Map.merge(attributes, %{created_at: now, updated_at: now})
tag_attributes = %{name: "", slug: "", created_at: now, updated_at: now}
diff --git a/lib/philomena/tag_changes/limits.ex b/lib/philomena/tag_changes/limits.ex
new file mode 100644
index 00000000..f30d2d90
--- /dev/null
+++ b/lib/philomena/tag_changes/limits.ex
@@ -0,0 +1,109 @@
+defmodule Philomena.TagChanges.Limits do
+ @moduledoc """
+ Tag change limits for anonymous users.
+ """
+
+ @tag_changes_per_ten_minutes 50
+ @rating_changes_per_ten_minutes 1
+ @ten_minutes_in_seconds 10 * 60
+
+ @doc """
+ Determine if the current user and IP can make any tag changes at all.
+
+ The user may be limited due to making more than 50 tag changes in the past 10 minutes.
+ Should be used in tandem with `update_tag_count_after_update/3`.
+
+ ## Examples
+
+ iex> limited_for_tag_count?(%User{}, %Postgrex.INET{})
+ false
+
+ iex> limited_for_tag_count?(%User{}, %Postgrex.INET{}, 72)
+ true
+
+ """
+ def limited_for_tag_count?(user, ip, additional \\ 0) do
+ check_limit(user, tag_count_key_for_ip(ip), @tag_changes_per_ten_minutes, additional)
+ end
+
+ @doc """
+ Determine if the current user and IP can make rating tag changes.
+
+ The user may be limited due to making more than one rating tag change in the past 10 minutes.
+ Should be used in tandem with `update_rating_count_after_update/3`.
+
+ ## Examples
+
+ iex> limited_for_rating_count?(%User{}, %Postgrex.INET{})
+ false
+
+ iex> limited_for_rating_count?(%User{}, %Postgrex.INET{}, 2)
+ true
+
+ """
+ def limited_for_rating_count?(user, ip) do
+ check_limit(user, rating_count_key_for_ip(ip), @rating_changes_per_ten_minutes, 0)
+ end
+
+ @doc """
+ Post-transaction update for successful tag changes.
+
+ Should be used in tandem with `limited_for_tag_count?/2`.
+
+ ## Examples
+
+ iex> update_tag_count_after_update(%User{}, %Postgrex.INET{}, 25)
+ :ok
+
+ """
+ def update_tag_count_after_update(user, ip, amount) do
+ increment_counter(user, tag_count_key_for_ip(ip), amount, @ten_minutes_in_seconds)
+ end
+
+ @doc """
+ Post-transaction update for successful rating tag changes.
+
+ Should be used in tandem with `limited_for_rating_count?/2`.
+
+ ## Examples
+
+ iex> update_rating_count_after_update(%User{}, %Postgrex.INET{}, 1)
+ :ok
+
+ """
+ def update_rating_count_after_update(user, ip, amount) do
+ increment_counter(user, rating_count_key_for_ip(ip), amount, @ten_minutes_in_seconds)
+ end
+
+ defp check_limit(user, key, limit, additional) do
+ if considered_for_limit?(user) do
+ amt = String.to_integer(Redix.command!(:redix, ["GET", key]) || "0")
+ amt + additional >= limit
+ else
+ false
+ end
+ end
+
+ defp increment_counter(user, key, amount, expiration) do
+ if considered_for_limit?(user) do
+ Redix.pipeline!(:redix, [
+ ["INCRBY", key, amount],
+ ["EXPIRE", key, expiration]
+ ])
+ end
+
+ :ok
+ end
+
+ defp considered_for_limit?(user) do
+ is_nil(user) or not user.verified
+ end
+
+ defp tag_count_key_for_ip(ip) do
+ "rltcn:#{ip}"
+ end
+
+ defp rating_count_key_for_ip(ip) do
+ "rltcr:#{ip}"
+ end
+end
diff --git a/lib/philomena/tag_changes/tag_change.ex b/lib/philomena/tag_changes/tag_change.ex
index 3bc9eb10..6d33397d 100644
--- a/lib/philomena/tag_changes/tag_change.ex
+++ b/lib/philomena/tag_changes/tag_change.ex
@@ -9,8 +9,6 @@ defmodule Philomena.TagChanges.TagChange do
field :ip, EctoNetwork.INET
field :fingerprint, :string
- field :user_agent, :string, default: ""
- field :referrer, :string, default: ""
field :added, :boolean
field :tag_name_cache, :string, default: ""
diff --git a/lib/philomena/tags.ex b/lib/philomena/tags.ex
index 0d759e93..d6c6898a 100644
--- a/lib/philomena/tags.ex
+++ b/lib/philomena/tags.ex
@@ -81,6 +81,22 @@ defmodule Philomena.Tags do
"""
def get_tag!(id), do: Repo.get!(Tag, id)
+ @doc """
+ Gets a single tag.
+
+ Returns nil if the Tag does not exist.
+
+ ## Examples
+
+ iex> get_tag_by_name("safe")
+ %Tag{}
+
+ iex> get_tag_by_name("nonexistent")
+ nil
+
+ """
+ def get_tag_by_name(name), do: Repo.get_by(Tag, name: name)
+
@doc """
Gets a single tag by its name, or the tag it is aliased to, if it is aliased.
diff --git a/lib/philomena/tags/query.ex b/lib/philomena/tags/query.ex
index da148da4..6af25454 100644
--- a/lib/philomena/tags/query.ex
+++ b/lib/philomena/tags/query.ex
@@ -20,6 +20,6 @@ defmodule Philomena.Tags.Query do
def compile(query_string) do
fields()
|> Parser.new()
- |> Parser.parse(query_string || "")
+ |> Parser.parse(query_string)
end
end
diff --git a/lib/philomena/tags/search_index.ex b/lib/philomena/tags/search_index.ex
index ec681a3f..4589c065 100644
--- a/lib/philomena/tags/search_index.ex
+++ b/lib/philomena/tags/search_index.ex
@@ -71,7 +71,7 @@ defmodule Philomena.Tags.SearchIndex do
category: tag.category,
aliased: !!tag.aliased_tag,
description: tag.description,
- short_description: tag.description
+ short_description: tag.short_description
}
end
end
diff --git a/lib/philomena/topics.ex b/lib/philomena/topics.ex
index 4892c06d..38a5d602 100644
--- a/lib/philomena/topics.ex
+++ b/lib/philomena/topics.ex
@@ -11,8 +11,10 @@ defmodule Philomena.Topics do
alias Philomena.Forums.Forum
alias Philomena.Posts
alias Philomena.Notifications
- alias Philomena.NotificationWorker
- alias Philomena.Users.User
+
+ use Philomena.Subscriptions,
+ on_delete: :clear_topic_notification,
+ id_name: :topic_id
@doc """
Gets a single topic.
@@ -43,7 +45,7 @@ defmodule Philomena.Topics do
"""
def create_topic(forum, attribution, attrs \\ %{}) do
- now = DateTime.utc_now() |> DateTime.truncate(:second)
+ now = DateTime.utc_now(:second)
topic =
%Topic{}
@@ -70,7 +72,8 @@ defmodule Philomena.Topics do
{:ok, count}
end)
- |> maybe_create_subscription_on_new_topic(attribution[:user])
+ |> Multi.run(:notification, ¬ify_topic/2)
+ |> maybe_subscribe_on(:topic, attribution[:user], :watch_on_new_topic)
|> Repo.transaction()
|> case do
{:ok, %{topic: topic}} = result ->
@@ -84,46 +87,8 @@ defmodule Philomena.Topics do
end
end
- defp maybe_create_subscription_on_new_topic(multi, %User{watch_on_new_topic: true} = user) do
- multi
- |> Multi.run(:subscribe, fn _repo, %{topic: topic} ->
- create_subscription(topic, user)
- end)
- end
-
- defp maybe_create_subscription_on_new_topic(multi, _user) do
- multi
- end
-
- def notify_topic(topic, post) do
- Exq.enqueue(Exq, "notifications", NotificationWorker, ["Topics", [topic.id, post.id]])
- end
-
- def perform_notify([topic_id, post_id]) do
- topic = get_topic!(topic_id)
- post = Posts.get_post!(post_id)
-
- forum =
- topic
- |> Repo.preload(:forum)
- |> Map.fetch!(:forum)
-
- subscriptions =
- forum
- |> Repo.preload(:subscriptions)
- |> Map.fetch!(:subscriptions)
-
- Notifications.notify(
- post,
- subscriptions,
- %{
- actor_id: topic.id,
- actor_type: "Topic",
- actor_child_id: post.id,
- actor_child_type: "Post",
- action: "posted a new topic in #{forum.name}"
- }
- )
+ defp notify_topic(_repo, %{topic: topic}) do
+ Notifications.create_forum_topic_notification(topic.user, topic)
end
@doc """
@@ -173,55 +138,6 @@ defmodule Philomena.Topics do
Topic.changeset(topic, %{})
end
- alias Philomena.Topics.Subscription
-
- def subscribed?(_topic, nil), do: false
-
- def subscribed?(topic, user) do
- Subscription
- |> where(topic_id: ^topic.id, user_id: ^user.id)
- |> Repo.exists?()
- end
-
- @doc """
- Creates a subscription.
-
- ## Examples
-
- iex> create_subscription(%{field: value})
- {:ok, %Subscription{}}
-
- iex> create_subscription(%{field: bad_value})
- {:error, %Ecto.Changeset{}}
-
- """
- def create_subscription(_topic, nil), do: {:ok, nil}
-
- def create_subscription(topic, user) do
- %Subscription{topic_id: topic.id, user_id: user.id}
- |> Subscription.changeset(%{})
- |> Repo.insert(on_conflict: :nothing)
- end
-
- @doc """
- Deletes a Subscription.
-
- ## Examples
-
- iex> delete_subscription(subscription)
- {:ok, %Subscription{}}
-
- iex> delete_subscription(subscription)
- {:error, %Ecto.Changeset{}}
-
- """
- def delete_subscription(topic, user) do
- clear_notification(topic, user)
-
- %Subscription{topic_id: topic.id, user_id: user.id}
- |> Repo.delete()
- end
-
def stick_topic(topic) do
Topic.stick_changeset(topic)
|> Repo.update()
@@ -300,9 +216,18 @@ defmodule Philomena.Topics do
|> Repo.update()
end
- def clear_notification(_topic, nil), do: nil
+ @doc """
+ Removes all topic notifications for a given topic and user.
- def clear_notification(topic, user) do
- Notifications.delete_unread_notification("Topic", topic.id, user)
+ ## Examples
+
+ iex> clear_topic_notification(topic, user)
+ :ok
+
+ """
+ def clear_topic_notification(%Topic{} = topic, user) do
+ Notifications.clear_forum_post_notification(topic, user)
+ Notifications.clear_forum_topic_notification(topic, user)
+ :ok
end
end
diff --git a/lib/philomena/topics/topic.ex b/lib/philomena/topics/topic.ex
index 0db30126..d0e04c0b 100644
--- a/lib/philomena/topics/topic.ex
+++ b/lib/philomena/topics/topic.ex
@@ -58,7 +58,7 @@ defmodule Philomena.Topics.Topic do
|> put_slug()
|> change(forum: forum, user: attribution[:user])
|> validate_required(:forum)
- |> cast_assoc(:poll, with: &Poll.update_changeset/2)
+ |> cast_assoc(:poll, with: &Poll.changeset/2)
|> cast_assoc(:posts, with: &Post.topic_creation_changeset(&1, &2, attribution, anonymous?))
|> validate_length(:posts, is: 1)
|> unique_constraint(:slug, name: :index_topics_on_forum_id_and_slug)
@@ -75,11 +75,10 @@ defmodule Philomena.Topics.Topic do
end
def lock_changeset(topic, attrs, user) do
- now = DateTime.utc_now() |> DateTime.truncate(:second)
-
- change(topic)
+ topic
+ |> change()
|> cast(attrs, [:lock_reason])
- |> put_change(:locked_at, now)
+ |> put_change(:locked_at, DateTime.utc_now(:second))
|> put_change(:locked_by_id, user.id)
|> validate_required([:lock_reason])
end
diff --git a/lib/philomena/user_whitelists.ex b/lib/philomena/user_whitelists.ex
deleted file mode 100644
index 35615c23..00000000
--- a/lib/philomena/user_whitelists.ex
+++ /dev/null
@@ -1,104 +0,0 @@
-defmodule Philomena.UserWhitelists do
- @moduledoc """
- The UserWhitelists context.
- """
-
- import Ecto.Query, warn: false
- alias Philomena.Repo
-
- alias Philomena.UserWhitelists.UserWhitelist
-
- @doc """
- Returns the list of user_whitelists.
-
- ## Examples
-
- iex> list_user_whitelists()
- [%UserWhitelist{}, ...]
-
- """
- def list_user_whitelists do
- Repo.all(UserWhitelist)
- end
-
- @doc """
- Gets a single user_whitelist.
-
- Raises `Ecto.NoResultsError` if the User whitelist does not exist.
-
- ## Examples
-
- iex> get_user_whitelist!(123)
- %UserWhitelist{}
-
- iex> get_user_whitelist!(456)
- ** (Ecto.NoResultsError)
-
- """
- def get_user_whitelist!(id), do: Repo.get!(UserWhitelist, id)
-
- @doc """
- Creates a user_whitelist.
-
- ## Examples
-
- iex> create_user_whitelist(%{field: value})
- {:ok, %UserWhitelist{}}
-
- iex> create_user_whitelist(%{field: bad_value})
- {:error, %Ecto.Changeset{}}
-
- """
- def create_user_whitelist(attrs \\ %{}) do
- %UserWhitelist{}
- |> UserWhitelist.changeset(attrs)
- |> Repo.insert()
- end
-
- @doc """
- Updates a user_whitelist.
-
- ## Examples
-
- iex> update_user_whitelist(user_whitelist, %{field: new_value})
- {:ok, %UserWhitelist{}}
-
- iex> update_user_whitelist(user_whitelist, %{field: bad_value})
- {:error, %Ecto.Changeset{}}
-
- """
- def update_user_whitelist(%UserWhitelist{} = user_whitelist, attrs) do
- user_whitelist
- |> UserWhitelist.changeset(attrs)
- |> Repo.update()
- end
-
- @doc """
- Deletes a UserWhitelist.
-
- ## Examples
-
- iex> delete_user_whitelist(user_whitelist)
- {:ok, %UserWhitelist{}}
-
- iex> delete_user_whitelist(user_whitelist)
- {:error, %Ecto.Changeset{}}
-
- """
- def delete_user_whitelist(%UserWhitelist{} = user_whitelist) do
- Repo.delete(user_whitelist)
- end
-
- @doc """
- Returns an `%Ecto.Changeset{}` for tracking user_whitelist changes.
-
- ## Examples
-
- iex> change_user_whitelist(user_whitelist)
- %Ecto.Changeset{source: %UserWhitelist{}}
-
- """
- def change_user_whitelist(%UserWhitelist{} = user_whitelist) do
- UserWhitelist.changeset(user_whitelist, %{})
- end
-end
diff --git a/lib/philomena/user_whitelists/user_whitelist.ex b/lib/philomena/user_whitelists/user_whitelist.ex
deleted file mode 100644
index 7bee5b36..00000000
--- a/lib/philomena/user_whitelists/user_whitelist.ex
+++ /dev/null
@@ -1,20 +0,0 @@
-defmodule Philomena.UserWhitelists.UserWhitelist do
- use Ecto.Schema
- import Ecto.Changeset
-
- alias Philomena.Users.User
-
- schema "user_whitelists" do
- belongs_to :user, User
-
- field :reason, :string
- timestamps(inserted_at: :created_at, type: :utc_datetime)
- end
-
- @doc false
- def changeset(user_whitelist, attrs) do
- user_whitelist
- |> cast(attrs, [])
- |> validate_required([])
- end
-end
diff --git a/lib/philomena/users.ex b/lib/philomena/users.ex
index b971f0c9..575552aa 100644
--- a/lib/philomena/users.ex
+++ b/lib/philomena/users.ex
@@ -18,6 +18,7 @@ defmodule Philomena.Users do
alias Philomena.Galleries
alias Philomena.Reports
alias Philomena.Filters
+ alias Philomena.UserEraseWorker
alias Philomena.UserRenameWorker
## Database getters
@@ -54,6 +55,22 @@ defmodule Philomena.Users do
Repo.get_by(User, email: email)
end
+ @doc """
+ Gets a user by name.
+
+ ## Examples
+
+ iex> get_user_by_name("Administrator")
+ %User{}
+
+ iex> get_user_by_name("nonexistent")
+ nil
+
+ """
+ def get_user_by_name(name) when is_binary(name) do
+ Repo.get_by(User, name: name)
+ end
+
@doc """
Gets a user by email and password.
@@ -683,6 +700,20 @@ defmodule Philomena.Users do
|> Repo.update()
end
+ def erase_user(%User{} = user, %User{} = moderator) do
+ # Deactivate to prevent the user from racing these changes
+ {:ok, user} = deactivate_user(moderator, user)
+
+ # Rename to prevent usage for brand recognition SEO
+ random_hex = Base.encode16(:crypto.strong_rand_bytes(16), case: :lower)
+ {:ok, user} = update_user(user, %{name: "deactivated_#{random_hex}"})
+
+ # Enqueue a background job to perform the rest of the deletion
+ Exq.enqueue(Exq, "indexing", UserEraseWorker, [user.id, moderator.id])
+
+ {:ok, user}
+ end
+
defp setup_roles(nil), do: nil
defp setup_roles(user) do
diff --git a/lib/philomena/users/eraser.ex b/lib/philomena/users/eraser.ex
new file mode 100644
index 00000000..cde0745f
--- /dev/null
+++ b/lib/philomena/users/eraser.ex
@@ -0,0 +1,123 @@
+defmodule Philomena.Users.Eraser do
+ import Ecto.Query
+ alias Philomena.Repo
+
+ alias Philomena.Bans
+ alias Philomena.Comments.Comment
+ alias Philomena.Comments
+ alias Philomena.Galleries.Gallery
+ alias Philomena.Galleries
+ alias Philomena.Posts.Post
+ alias Philomena.Posts
+ alias Philomena.Topics.Topic
+ alias Philomena.Topics
+ alias Philomena.Images
+ alias Philomena.SourceChanges.SourceChange
+
+ alias Philomena.Users
+
+ @reason "Site abuse"
+ @wipe_ip %Postgrex.INET{address: {127, 0, 1, 1}, netmask: 32}
+ @wipe_fp "ffff"
+
+ def erase_permanently!(user, moderator) do
+ # Erase avatar
+ {:ok, user} = Users.remove_avatar(user)
+
+ # Erase "about me" and personal title
+ {:ok, user} = Users.update_description(user, %{description: "", personal_title: ""})
+
+ # Delete all forum posts
+ Post
+ |> where(user_id: ^user.id)
+ |> Repo.all()
+ |> Enum.each(fn post ->
+ {:ok, post} = Posts.hide_post(post, %{deletion_reason: @reason}, moderator)
+ {:ok, _post} = Posts.destroy_post(post)
+ end)
+
+ # Delete all comments
+ Comment
+ |> where(user_id: ^user.id)
+ |> Repo.all()
+ |> Enum.each(fn comment ->
+ {:ok, comment} = Comments.hide_comment(comment, %{deletion_reason: @reason}, moderator)
+ {:ok, _comment} = Comments.destroy_comment(comment)
+ end)
+
+ # Delete all galleries
+ Gallery
+ |> where(creator_id: ^user.id)
+ |> Repo.all()
+ |> Enum.each(fn gallery ->
+ {:ok, _gallery} = Galleries.delete_gallery(gallery)
+ end)
+
+ # Delete all posted topics
+ Topic
+ |> where(user_id: ^user.id)
+ |> Repo.all()
+ |> Enum.each(fn topic ->
+ {:ok, _topic} = Topics.hide_topic(topic, @reason, moderator)
+ end)
+
+ # Revert all source changes
+ SourceChange
+ |> where(user_id: ^user.id)
+ |> order_by(desc: :created_at)
+ |> preload(:image)
+ |> Repo.all()
+ |> Enum.each(fn source_change ->
+ if source_change.added do
+ revert_added_source_change(source_change, user)
+ else
+ revert_removed_source_change(source_change, user)
+ end
+ end)
+
+ # Delete all source changes
+ SourceChange
+ |> where(user_id: ^user.id)
+ |> Repo.delete_all()
+
+ # Ban the user
+ {:ok, _ban} =
+ Bans.create_user(
+ moderator,
+ %{
+ "user_id" => user.id,
+ "reason" => @reason,
+ "valid_until" => "permanent"
+ }
+ )
+
+ # We succeeded
+ :ok
+ end
+
+ defp revert_removed_source_change(source_change, user) do
+ old_sources = %{}
+ new_sources = %{"0" => %{"source" => source_change.source_url}}
+
+ revert_source_change(source_change, user, old_sources, new_sources)
+ end
+
+ defp revert_added_source_change(source_change, user) do
+ old_sources = %{"0" => %{"source" => source_change.source_url}}
+ new_sources = %{}
+
+ revert_source_change(source_change, user, old_sources, new_sources)
+ end
+
+ defp revert_source_change(source_change, user, old_sources, new_sources) do
+ attrs = %{"old_sources" => old_sources, "sources" => new_sources}
+
+ attribution = [
+ user: user,
+ ip: @wipe_ip,
+ fingerprint: @wipe_fp
+ ]
+
+ {:ok, _} = Images.update_sources(source_change.image, attribution, attrs)
+ end
+end
diff --git a/lib/philomena/users/user.ex b/lib/philomena/users/user.ex
index 8bb2f693..aa41ebec 100644
--- a/lib/philomena/users/user.ex
+++ b/lib/philomena/users/user.ex
@@ -6,14 +6,14 @@ defmodule Philomena.Users.User do
use Ecto.Schema
import Ecto.Changeset
+ import PhilomenaQuery.Ecto.QueryValidator
alias Philomena.Schema.TagList
- alias Philomena.Schema.Search
+ alias Philomena.Images.Query
alias Philomena.Filters.Filter
alias Philomena.ArtistLinks.ArtistLink
alias Philomena.Badges
- alias Philomena.Notifications.UnreadNotification
alias Philomena.Galleries.Gallery
alias Philomena.Users.User
alias Philomena.Commissions.Commission
@@ -31,8 +31,6 @@ defmodule Philomena.Users.User do
has_many :public_links, ArtistLink, where: [public: true, aasm_state: "verified"]
has_many :galleries, Gallery, foreign_key: :creator_id
has_many :awards, Badges.Award
- has_many :unread_notifications, UnreadNotification
- has_many :notifications, through: [:unread_notifications, :notification]
has_many :linked_tags, through: [:verified_links, :tag]
has_many :user_ips, UserIp
has_many :user_fingerprints, UserFingerprint
@@ -113,7 +111,6 @@ defmodule Philomena.Users.User do
field :watched_tag_list, :string, virtual: true
# Other stuff
- field :last_donation_at, :utc_datetime
field :last_renamed_at, :utc_datetime
field :deleted_at, :utc_datetime
field :scratchpad, :string
@@ -217,8 +214,7 @@ defmodule Philomena.Users.User do
Confirms the account by setting `confirmed_at`.
"""
def confirm_changeset(user) do
- now = DateTime.utc_now() |> DateTime.truncate(:second)
- change(user, confirmed_at: now)
+ change(user, confirmed_at: DateTime.utc_now(:second))
end
@doc """
@@ -261,9 +257,7 @@ defmodule Philomena.Users.User do
end
def lock_changeset(user) do
- locked_at = DateTime.utc_now() |> DateTime.truncate(:second)
-
- change(user, locked_at: locked_at)
+ change(user, locked_at: DateTime.utc_now(:second))
end
def unlock_changeset(user) do
@@ -362,8 +356,8 @@ defmodule Philomena.Users.User do
|> validate_inclusion(:images_per_page, 1..50)
|> validate_inclusion(:comments_per_page, 1..100)
|> validate_inclusion(:scale_large_images, ["false", "partscaled", "true"])
- |> Search.validate_search(:watched_images_query_str, user, true)
- |> Search.validate_search(:watched_images_exclude_str, user, true)
+ |> validate_query(:watched_images_query_str, &Query.compile(&1, user: user, watch: true))
+ |> validate_query(:watched_images_exclude_str, &Query.compile(&1, user: user, watch: true))
end
def description_changeset(user, attrs) do
@@ -383,14 +377,12 @@ defmodule Philomena.Users.User do
end
def name_changeset(user, attrs) do
- now = DateTime.utc_now() |> DateTime.truncate(:second)
-
user
|> cast(attrs, [:name])
|> validate_name()
|> put_slug()
|> unique_constraints()
- |> put_change(:last_renamed_at, now)
+ |> put_change(:last_renamed_at, DateTime.utc_now(:second))
end
def avatar_changeset(user, attrs) do
@@ -433,7 +425,7 @@ defmodule Philomena.Users.User do
end
def deactivate_changeset(user, moderator) do
- now = DateTime.utc_now() |> DateTime.truncate(:second)
+ now = DateTime.utc_now(:second)
change(user, deleted_at: now, deleted_by_user_id: moderator.id)
end
diff --git a/lib/philomena/vpns.ex b/lib/philomena/vpns.ex
deleted file mode 100644
index b24254ec..00000000
--- a/lib/philomena/vpns.ex
+++ /dev/null
@@ -1,104 +0,0 @@
-defmodule Philomena.Vpns do
- @moduledoc """
- The Vpns context.
- """
-
- import Ecto.Query, warn: false
- alias Philomena.Repo
-
- alias Philomena.Vpns.Vpn
-
- @doc """
- Returns the list of vpns.
-
- ## Examples
-
- iex> list_vpns()
- [%Vpn{}, ...]
-
- """
- def list_vpns do
- Repo.all(Vpn)
- end
-
- @doc """
- Gets a single vpn.
-
- Raises `Ecto.NoResultsError` if the Vpn does not exist.
-
- ## Examples
-
- iex> get_vpn!(123)
- %Vpn{}
-
- iex> get_vpn!(456)
- ** (Ecto.NoResultsError)
-
- """
- def get_vpn!(id), do: Repo.get!(Vpn, id)
-
- @doc """
- Creates a vpn.
-
- ## Examples
-
- iex> create_vpn(%{field: value})
- {:ok, %Vpn{}}
-
- iex> create_vpn(%{field: bad_value})
- {:error, %Ecto.Changeset{}}
-
- """
- def create_vpn(attrs \\ %{}) do
- %Vpn{}
- |> Vpn.changeset(attrs)
- |> Repo.insert()
- end
-
- @doc """
- Updates a vpn.
-
- ## Examples
-
- iex> update_vpn(vpn, %{field: new_value})
- {:ok, %Vpn{}}
-
- iex> update_vpn(vpn, %{field: bad_value})
- {:error, %Ecto.Changeset{}}
-
- """
- def update_vpn(%Vpn{} = vpn, attrs) do
- vpn
- |> Vpn.changeset(attrs)
- |> Repo.update()
- end
-
- @doc """
- Deletes a Vpn.
-
- ## Examples
-
- iex> delete_vpn(vpn)
- {:ok, %Vpn{}}
-
- iex> delete_vpn(vpn)
- {:error, %Ecto.Changeset{}}
-
- """
- def delete_vpn(%Vpn{} = vpn) do
- Repo.delete(vpn)
- end
-
- @doc """
- Returns an `%Ecto.Changeset{}` for tracking vpn changes.
-
- ## Examples
-
- iex> change_vpn(vpn)
- %Ecto.Changeset{source: %Vpn{}}
-
- """
- def change_vpn(%Vpn{} = vpn) do
- Vpn.changeset(vpn, %{})
- end
-end
diff --git a/lib/philomena/vpns/vpn.ex b/lib/philomena/vpns/vpn.ex
deleted file mode 100644
index 6ce9fdd9..00000000
--- a/lib/philomena/vpns/vpn.ex
+++ /dev/null
@@ -1,17 +0,0 @@
-defmodule Philomena.Vpns.Vpn do
- use Ecto.Schema
- import Ecto.Changeset
-
- @primary_key false
-
- schema "vpns" do
- field :ip, EctoNetwork.INET
- end
-
- @doc false
- def changeset(vpn, attrs) do
- vpn
- |> cast(attrs, [])
- |> validate_required([])
- end
-end
diff --git a/lib/philomena/workers/notification_worker.ex b/lib/philomena/workers/notification_worker.ex
deleted file mode 100644
index 8bec8e61..00000000
--- a/lib/philomena/workers/notification_worker.ex
+++ /dev/null
@@ -1,13 +0,0 @@
-defmodule Philomena.NotificationWorker do
- @modules %{
- "Comments" => Philomena.Comments,
- "Galleries" => Philomena.Galleries,
- "Images" => Philomena.Images,
- "Posts" => Philomena.Posts,
- "Topics" => Philomena.Topics
- }
-
- def perform(module, args) do
- @modules[module].perform_notify(args)
- end
-end
diff --git a/lib/philomena/workers/user_erase_worker.ex b/lib/philomena/workers/user_erase_worker.ex
new file mode 100644
index 00000000..32c862d3
--- /dev/null
+++ b/lib/philomena/workers/user_erase_worker.ex
@@ -0,0 +1,11 @@
+defmodule Philomena.UserEraseWorker do
+ alias Philomena.Users.Eraser
+ alias Philomena.Users
+
+ def perform(user_id, moderator_id) do
+ moderator = Users.get_user!(moderator_id)
+ user = Users.get_user!(user_id)
+
+ Eraser.erase_permanently!(user, moderator)
+ end
+end
diff --git a/lib/philomena_media/analyzers.ex b/lib/philomena_media/analyzers.ex
index efa49d9a..7c97e845 100644
--- a/lib/philomena_media/analyzers.ex
+++ b/lib/philomena_media/analyzers.ex
@@ -40,25 +40,32 @@ defmodule PhilomenaMedia.Analyzers do
def analyzer(_content_type), do: :error
@doc """
- Attempts a MIME type check and analysis on the given path or `m:Plug.Upload`.
+ Attempts a MIME type check and analysis on the given `m:Plug.Upload`.
+
+ ## Examples
+
+ file = %Plug.Upload{...}
+ {:ok, %Result{...}} = Analyzers.analyze_upload(file)
+
+ """
+ @spec analyze_upload(Plug.Upload.t()) ::
+ {:ok, Result.t()} | {:unsupported_mime, Mime.t()} | :error
+ def analyze_upload(%Plug.Upload{path: path}), do: analyze_path(path)
+ def analyze_upload(_upload), do: :error
+
+ @doc """
+ Attempts a MIME type check and analysis on the given path.
## Examples
file = "image_file.png"
- {:ok, %Result{...}} = Analyzers.analyze(file)
-
- file = %Plug.Upload{...}
- {:ok, %Result{...}} = Analyzers.analyze(file)
+ {:ok, %Result{...}} = Analyzers.analyze_path(file)
file = "text_file.txt"
- :error = Analyzers.analyze(file)
+ :error = Analyzers.analyze_path(file)
"""
- @spec analyze(Plug.Upload.t() | Path.t()) ::
- {:ok, Result.t()} | {:unsupported_mime, Mime.t()} | :error
- def analyze(%Plug.Upload{path: path}), do: analyze(path)
-
- def analyze(path) when is_binary(path) do
+ def analyze_path(path) when is_binary(path) do
with {:ok, mime} <- Mime.file(path),
{:ok, analyzer} <- analyzer(mime) do
{:ok, analyzer.analyze(path)}
@@ -68,5 +75,5 @@ defmodule PhilomenaMedia.Analyzers do
end
end
- def analyze(_path), do: :error
+ def analyze_path(_path), do: :error
end
diff --git a/lib/philomena_media/uploader.ex b/lib/philomena_media/uploader.ex
index 3df61945..7248d92c 100644
--- a/lib/philomena_media/uploader.ex
+++ b/lib/philomena_media/uploader.ex
@@ -130,6 +130,7 @@ defmodule PhilomenaMedia.Uploader do
* `width` (integer) - the width of the file
* `height` (integer) - the height of the file
* `size` (integer) - the size of the file, in bytes
+ * `orig_size` (integer) - the size of the file, in bytes
* `format` (String) - the file extension, one of `~w(gif jpg png svg webm)`, determined by reading the file
* `mime_type` (String) - the file's sniffed MIME type, determined by reading the file
* `duration` (float) - the duration of the media file
@@ -148,6 +149,7 @@ defmodule PhilomenaMedia.Uploader do
:foo_width,
:foo_height,
:foo_size,
+ :foo_orig_size,
:foo_format,
:foo_mime_type,
:foo_duration,
@@ -208,7 +210,7 @@ defmodule PhilomenaMedia.Uploader do
(schema_or_changeset(), map() -> Ecto.Changeset.t())
) :: Ecto.Changeset.t()
def analyze_upload(schema_or_changeset, field_name, upload_parameter, changeset_fn) do
- with {:ok, analysis} <- Analyzers.analyze(upload_parameter),
+ with {:ok, analysis} <- Analyzers.analyze_upload(upload_parameter),
analysis <- extra_attributes(analysis, upload_parameter) do
removed =
schema_or_changeset
@@ -221,6 +223,7 @@ defmodule PhilomenaMedia.Uploader do
"width" => analysis.width,
"height" => analysis.height,
"size" => analysis.size,
+ "orig_size" => analysis.size,
"format" => analysis.extension,
"mime_type" => analysis.mime_type,
"duration" => analysis.duration,
diff --git a/lib/philomena_proxy/http.ex b/lib/philomena_proxy/http.ex
index 5558f697..a9c03e69 100644
--- a/lib/philomena_proxy/http.ex
+++ b/lib/philomena_proxy/http.ex
@@ -84,7 +84,7 @@ defmodule PhilomenaProxy.Http do
body: body,
headers: [{:user_agent, @user_agent} | headers],
max_redirects: 1,
- connect_options: connect_options(url),
+ connect_options: connect_options(),
inet6: true,
into: &stream_response_callback/2,
decode_body: false
@@ -93,39 +93,14 @@ defmodule PhilomenaProxy.Http do
|> Req.request()
end
- defp connect_options(url) do
- transport_opts =
- case URI.parse(url) do
- %{scheme: "https"} ->
- # SSL defaults validate SHA-1 on root certificates but this is unnecessary because many
- # many roots are still signed with SHA-1 and it isn't relevant for security. Relax to
- # allow validation of SHA-1, even though this creates a less secure client.
- # https://github.com/erlang/otp/issues/8601
- [
- transport_opts: [
- customize_hostname_check: [
- match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
- ],
- signature_algs_cert: :ssl.signature_algs(:default, :"tlsv1.3") ++ [sha: :rsa]
- ]
- ]
+ defp connect_options do
+ case Application.get_env(:philomena, :proxy_host) do
+ nil ->
+ []
- _ ->
- # Do not pass any options for non-HTTPS schemes. Finch will raise badarg if the above
- # options are passed.
- []
- end
-
- proxy_opts =
- case Application.get_env(:philomena, :proxy_host) do
- nil ->
- []
-
- url ->
- [proxy: proxy_opts(URI.parse(url))]
- end
-
- transport_opts ++ proxy_opts
+ proxy_url ->
+ [proxy: proxy_opts(URI.parse(proxy_url))]
+ end
end
defp proxy_opts(%{host: host, port: port, scheme: "https"}),
diff --git a/lib/philomena_query/ecto/query_validator.ex b/lib/philomena_query/ecto/query_validator.ex
new file mode 100644
index 00000000..ea0b8950
--- /dev/null
+++ b/lib/philomena_query/ecto/query_validator.ex
@@ -0,0 +1,69 @@
+defmodule PhilomenaQuery.Ecto.QueryValidator do
+ @moduledoc """
+ Query string validation for Ecto.
+
+ It enables the following usage pattern by taking a fn of the compiler:
+
+ defmodule Filter do
+ import PhilomenaQuery.Ecto.QueryValidator
+
+ # ...
+
+ def changeset(filter, attrs, user) do
+ filter
+ |> cast(attrs, [:complex])
+ |> validate_required([:complex])
+ |> validate_query([:complex], with: &Query.compile(&1, user: user))
+ end
+ end
+
+ """
+
+ import Ecto.Changeset
+ alias PhilomenaQuery.Parse.String
+
+ @doc """
+ Validates a query string using the provided attribute(s) and compiler.
+
+ Returns the changeset as-is, or with an `"is invalid"` error added to validated field.
+
+ ## Examples
+
+ # With single attribute
+ filter
+ |> cast(attrs, [:complex])
+ |> validate_query(:complex, &Query.compile(&1, user: user))
+
+ # With list of attributes
+ filter
+ |> cast(attrs, [:spoilered_complex, :hidden_complex])
+ |> validate_query([:spoilered_complex, :hidden_complex], &Query.compile(&1, user: user))
+
+ """
+ def validate_query(changeset, attr_or_attr_list, callback)
+
+ def validate_query(changeset, attr_list, callback) when is_list(attr_list) do
+ Enum.reduce(attr_list, changeset, fn attr, changeset ->
+ validate_query(changeset, attr, callback)
+ end)
+ end
+
+ def validate_query(changeset, attr, callback) do
+ if changed?(changeset, attr) do
+ validate_assuming_changed(changeset, attr, callback)
+ else
+ changeset
+ end
+ end
+
+ defp validate_assuming_changed(changeset, attr, callback) do
+ with value when is_binary(value) <- fetch_change!(changeset, attr) || "",
+ value <- String.normalize(value),
+ {:ok, _} <- callback.(value) do
+ changeset
+ else
+ _ ->
+ add_error(changeset, attr, "is invalid")
+ end
+ end
+end
diff --git a/lib/philomena_query/ecto/relative_date.ex b/lib/philomena_query/ecto/relative_date.ex
new file mode 100644
index 00000000..2916dcb7
--- /dev/null
+++ b/lib/philomena_query/ecto/relative_date.ex
@@ -0,0 +1,65 @@
+defmodule PhilomenaQuery.Ecto.RelativeDate do
+ @moduledoc """
+ Ecto custom type for relative dates.
+
+ As a field type, it enables the following usage pattern:
+
+ defmodule Notice do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "notices" do
+ field :start_date, PhilomenaQuery.Ecto.RelativeDate
+ field :finish_date, PhilomenaQuery.Ecto.RelativeDate
+ end
+
+ @doc false
+ def changeset(notice, attrs) do
+ notice
+ |> cast(attrs, [:start_date, :finish_date])
+ |> validate_required([:start_date, :finish_date])
+ end
+ end
+
+ """
+
+ use Ecto.Type
+ alias PhilomenaQuery.RelativeDate
+
+ @doc false
+ def type do
+ :utc_datetime
+ end
+
+ @doc false
+ def cast(input)
+
+ def cast(input) when is_binary(input) do
+ case RelativeDate.parse(input) do
+ {:ok, result} ->
+ {:ok, result}
+
+ _ ->
+ {:error, [message: "is not a valid relative or absolute date and time"]}
+ end
+ end
+
+ def cast(%DateTime{} = input) do
+ {:ok, input}
+ end
+
+ @doc false
+ def load(datetime) do
+ datetime =
+ datetime
+ |> DateTime.from_naive!("Etc/UTC")
+ |> DateTime.truncate(:second)
+
+ {:ok, datetime}
+ end
+
+ @doc false
+ def dump(datetime) do
+ {:ok, datetime}
+ end
+end
diff --git a/lib/philomena_query/parse/parser.ex b/lib/philomena_query/parse/parser.ex
index a89434d2..1b5f269d 100644
--- a/lib/philomena_query/parse/parser.ex
+++ b/lib/philomena_query/parse/parser.ex
@@ -184,18 +184,18 @@ defmodule PhilomenaQuery.Parse.Parser do
@spec parse(t(), String.t(), context()) :: {:ok, query()} | {:error, String.t()}
def parse(parser, input, context \\ nil)
- # Empty search should emit a match_none.
- def parse(_parser, "", _context) do
- {:ok, %{match_none: %{}}}
- end
-
def parse(%Parser{} = parser, input, context) do
parser = %{parser | __data__: context}
- with {:ok, tokens, _1, _2, _3, _4} <- Lexer.lex(input),
+ with {:ok, input} <- coerce_string(input),
+ {:ok, tokens, _1, _2, _3, _4} <- Lexer.lex(input),
+ {:ok, tokens} <- convert_empty_token_list(tokens),
{:ok, {tree, []}} <- search_top(parser, tokens) do
{:ok, tree}
else
+ {:error, :empty_query} ->
+ {:ok, %{match_none: %{}}}
+
{:ok, {_tree, tokens}} ->
{:error, "junk at end of expression: " <> debug_tokens(tokens)}
@@ -211,6 +211,13 @@ defmodule PhilomenaQuery.Parse.Parser do
end
end
+ defp coerce_string(term) when is_binary(term), do: {:ok, term}
+ defp coerce_string(nil), do: {:ok, ""}
+ defp coerce_string(_), do: {:error, "search query is not a string"}
+
+ defp convert_empty_token_list([]), do: {:error, :empty_query}
+ defp convert_empty_token_list(tokens), do: {:ok, tokens}
+
defp debug_tokens(tokens) do
Enum.map_join(tokens, fn {_k, v} -> v end)
end
@@ -354,6 +361,9 @@ defmodule PhilomenaQuery.Parse.Parser do
{%{wildcard: %{field(parser, field_name) => normalize_value(parser, field_name, value)}},
[]}}
+ defp field_type(_parser, [{LiteralParser, field_name}, _range, _value]),
+ do: {:error, "range specified for " <> field_name}
+
defp field_type(parser, [{NgramParser, field_name}, range: :eq, literal: value]),
do:
{:ok,
@@ -377,12 +387,21 @@ defmodule PhilomenaQuery.Parse.Parser do
{%{wildcard: %{field(parser, field_name) => normalize_value(parser, field_name, value)}},
[]}}
+ defp field_type(_parser, [{NgramParser, field_name}, _range, _value]),
+ do: {:error, "range specified for " <> field_name}
+
defp field_type(parser, [{BoolParser, field_name}, range: :eq, bool: value]),
do: {:ok, {%{term: %{field(parser, field_name) => value}}, []}}
+ defp field_type(_parser, [{BoolParser, field_name}, _range, _value]),
+ do: {:error, "range specified for " <> field_name}
+
defp field_type(parser, [{IpParser, field_name}, range: :eq, ip: value]),
do: {:ok, {%{term: %{field(parser, field_name) => value}}, []}}
+ defp field_type(_parser, [{IpParser, field_name}, _range, _value]),
+ do: {:error, "range specified for " <> field_name}
+
# Types which do support ranges
defp field_type(parser, [{IntParser, field_name}, range: :eq, int: value]),
diff --git a/lib/philomena_query/relative_date.ex b/lib/philomena_query/relative_date.ex
index 35b0fc82..444bb4d0 100644
--- a/lib/philomena_query/relative_date.ex
+++ b/lib/philomena_query/relative_date.ex
@@ -42,12 +42,22 @@ defmodule PhilomenaQuery.RelativeDate do
space = ignore(repeat(string(" ")))
- moon =
+ permanent_specifier =
+ choice([
+ string("moon"),
+ string("forever"),
+ string("permanent"),
+ string("permanently"),
+ string("indefinite"),
+ string("indefinitely")
+ ])
+
+ permanent =
space
- |> string("moon")
+ |> concat(permanent_specifier)
|> concat(space)
|> eos()
- |> unwrap_and_tag(:moon)
+ |> unwrap_and_tag(:permanent)
now =
space
@@ -69,7 +79,7 @@ defmodule PhilomenaQuery.RelativeDate do
relative_date =
choice([
- moon,
+ permanent,
now,
date
])
@@ -117,7 +127,7 @@ defmodule PhilomenaQuery.RelativeDate do
def parse_absolute(input) do
case DateTime.from_iso8601(input) do
{:ok, datetime, _offset} ->
- {:ok, datetime |> DateTime.truncate(:second)}
+ {:ok, DateTime.truncate(datetime, :second)}
_error ->
{:error, "Parse error"}
@@ -144,19 +154,17 @@ defmodule PhilomenaQuery.RelativeDate do
"""
@spec parse_relative(String.t()) :: {:ok, DateTime.t()} | {:error, any()}
def parse_relative(input) do
+ now = DateTime.utc_now(:second)
+
case relative_date(input) do
- {:ok, [moon: _moon], _1, _2, _3, _4} ->
- {:ok,
- DateTime.utc_now() |> DateTime.add(31_536_000_000, :second) |> DateTime.truncate(:second)}
+ {:ok, [permanent: _permanent], _1, _2, _3, _4} ->
+ {:ok, DateTime.add(now, 31_536_000_000, :second)}
{:ok, [now: _now], _1, _2, _3, _4} ->
- {:ok, DateTime.utc_now() |> DateTime.truncate(:second)}
+ {:ok, now}
{:ok, [relative_date: [amount, scale, direction]], _1, _2, _3, _4} ->
- {:ok,
- DateTime.utc_now()
- |> DateTime.add(amount * scale * direction, :second)
- |> DateTime.truncate(:second)}
+ {:ok, DateTime.add(now, amount * scale * direction, :second)}
_error ->
{:error, "Parse error"}
diff --git a/lib/philomena_web/controllers/admin/advert_controller.ex b/lib/philomena_web/controllers/admin/advert_controller.ex
index e058ce26..12b1dfbe 100644
--- a/lib/philomena_web/controllers/admin/advert_controller.ex
+++ b/lib/philomena_web/controllers/admin/advert_controller.ex
@@ -34,7 +34,7 @@ defmodule PhilomenaWeb.Admin.AdvertController do
|> put_flash(:info, "Advert was successfully created.")
|> redirect(to: ~p"/admin/adverts")
- {:error, :advert, changeset, _changes} ->
+ {:error, changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
diff --git a/lib/philomena_web/controllers/admin/batch/tag_controller.ex b/lib/philomena_web/controllers/admin/batch/tag_controller.ex
index 44e6485b..835917a0 100644
--- a/lib/philomena_web/controllers/admin/batch/tag_controller.ex
+++ b/lib/philomena_web/controllers/admin/batch/tag_controller.ex
@@ -37,8 +37,6 @@ defmodule PhilomenaWeb.Admin.Batch.TagController do
attributes = %{
ip: attributes[:ip],
fingerprint: attributes[:fingerprint],
- user_agent: attributes[:user_agent],
- referrer: attributes[:referrer],
user_id: attributes[:user].id
}
diff --git a/lib/philomena_web/controllers/admin/mod_note_controller.ex b/lib/philomena_web/controllers/admin/mod_note_controller.ex
index f5b3999f..604e0140 100644
--- a/lib/philomena_web/controllers/admin/mod_note_controller.ex
+++ b/lib/philomena_web/controllers/admin/mod_note_controller.ex
@@ -5,33 +5,23 @@ defmodule PhilomenaWeb.Admin.ModNoteController do
alias Philomena.ModNotes.ModNote
alias Philomena.Polymorphic
alias Philomena.ModNotes
- alias Philomena.Repo
- import Ecto.Query
plug :verify_authorized
plug :load_resource, model: ModNote, only: [:edit, :update, :delete]
plug :preload_association when action in [:edit, :update, :delete]
- def index(conn, %{"q" => q}) do
- ModNote
- |> where([m], ilike(m.body, ^"%#{q}%"))
- |> load_mod_notes(conn)
- end
+ def index(conn, params) do
+ pagination = conn.assigns.scrivener
+ renderer = &MarkdownRenderer.render_collection(&1, conn)
- def index(conn, _params) do
- load_mod_notes(ModNote, conn)
- end
-
- defp load_mod_notes(queryable, conn) do
mod_notes =
- queryable
- |> preload(:moderator)
- |> order_by(desc: :id)
- |> Repo.paginate(conn.assigns.scrivener)
+ case params do
+ %{"q" => q} ->
+ ModNotes.list_mod_notes_by_query_string(q, renderer, pagination)
- bodies = MarkdownRenderer.render_collection(mod_notes, conn)
- preloaded = Polymorphic.load_polymorphic(mod_notes, notable: [notable_id: :notable_type])
- mod_notes = %{mod_notes | entries: Enum.zip(bodies, preloaded)}
+ _ ->
+ ModNotes.list_mod_notes(renderer, pagination)
+ end
render(conn, "index.html", title: "Admin - Mod Notes", mod_notes: mod_notes)
end
diff --git a/lib/philomena_web/controllers/admin/report_controller.ex b/lib/philomena_web/controllers/admin/report_controller.ex
index e6fc6a97..4dfcf505 100644
--- a/lib/philomena_web/controllers/admin/report_controller.ex
+++ b/lib/philomena_web/controllers/admin/report_controller.ex
@@ -7,6 +7,7 @@ defmodule PhilomenaWeb.Admin.ReportController do
alias Philomena.Reports.Query
alias Philomena.Polymorphic
alias Philomena.ModNotes.ModNote
+ alias Philomena.ModNotes
alias Philomena.Repo
import Ecto.Query
@@ -128,19 +129,8 @@ defmodule PhilomenaWeb.Admin.ReportController do
true ->
report = conn.assigns.report
- mod_notes =
- ModNote
- |> where(notable_type: "Report", notable_id: ^report.id)
- |> order_by(desc: :id)
- |> preload(:moderator)
- |> Repo.all()
- |> Polymorphic.load_polymorphic(notable: [notable_id: :notable_type])
-
- mod_notes =
- mod_notes
- |> MarkdownRenderer.render_collection(conn)
- |> Enum.zip(mod_notes)
-
+ renderer = &MarkdownRenderer.render_collection(&1, conn)
+ mod_notes = ModNotes.list_all_mod_notes_by_type_and_id("Report", report.id, renderer)
assign(conn, :mod_notes, mod_notes)
_false ->
diff --git a/lib/philomena_web/controllers/admin/user/erase_controller.ex b/lib/philomena_web/controllers/admin/user/erase_controller.ex
new file mode 100644
index 00000000..f0a926df
--- /dev/null
+++ b/lib/philomena_web/controllers/admin/user/erase_controller.ex
@@ -0,0 +1,71 @@
+defmodule PhilomenaWeb.Admin.User.EraseController do
+ use PhilomenaWeb, :controller
+
+ alias Philomena.Users.User
+ alias Philomena.Users
+
+ plug :verify_authorized
+
+ plug :load_resource,
+ model: User,
+ id_name: "user_id",
+ id_field: "slug",
+ persisted: true,
+ preload: [:roles]
+
+ plug :prevent_deleting_nonexistent_users
+ plug :prevent_deleting_privileged_users
+ plug :prevent_deleting_verified_users
+
+ def new(conn, _params) do
+ render(conn, "new.html", title: "Erase user")
+ end
+
+ def create(conn, _params) do
+ {:ok, user} = Users.erase_user(conn.assigns.user, conn.assigns.current_user)
+
+ conn
+ |> put_flash(:info, "User erase started")
+ |> redirect(to: ~p"/profiles/#{user}")
+ end
+
+ defp verify_authorized(conn, _opts) do
+ case Canada.Can.can?(conn.assigns.current_user, :index, User) do
+ true -> conn
+ _false -> PhilomenaWeb.NotAuthorizedPlug.call(conn)
+ end
+ end
+
+ defp prevent_deleting_nonexistent_users(conn, _opts) do
+ if is_nil(conn.assigns.user) do
+ conn
+ |> put_flash(:error, "Couldn't find that username. Was it already erased?")
+ |> redirect(to: ~p"/admin/users")
+ |> Plug.Conn.halt()
+ else
+ conn
+ end
+ end
+
+ defp prevent_deleting_privileged_users(conn, _opts) do
+ if conn.assigns.user.role != "user" do
+ conn
+ |> put_flash(:error, "Cannot erase a privileged user")
+ |> redirect(to: ~p"/profiles/#{conn.assigns.user}")
+ |> Plug.Conn.halt()
+ else
+ conn
+ end
+ end
+
+ defp prevent_deleting_verified_users(conn, _opts) do
+ if conn.assigns.user.verified do
+ conn
+ |> put_flash(:error, "Cannot erase a verified user")
+ |> redirect(to: ~p"/profiles/#{conn.assigns.user}")
+ |> Plug.Conn.halt()
+ else
+ conn
+ end
+ end
+end
diff --git a/lib/philomena_web/controllers/admin/user_ban_controller.ex b/lib/philomena_web/controllers/admin/user_ban_controller.ex
index ff6833c0..f79fecd7 100644
--- a/lib/philomena_web/controllers/admin/user_ban_controller.ex
+++ b/lib/philomena_web/controllers/admin/user_ban_controller.ex
@@ -1,13 +1,14 @@
defmodule PhilomenaWeb.Admin.UserBanController do
use PhilomenaWeb, :controller
+ alias Philomena.Users
alias Philomena.Bans.User, as: UserBan
alias Philomena.Bans
alias Philomena.Repo
import Ecto.Query
plug :verify_authorized
- plug :load_resource, model: UserBan, only: [:edit, :update, :delete]
+ plug :load_resource, model: UserBan, only: [:edit, :update, :delete], preload: :user
plug :check_can_delete when action in [:delete]
def index(conn, %{"q" => q}) when is_binary(q) do
@@ -35,14 +36,21 @@ defmodule PhilomenaWeb.Admin.UserBanController do
load_bans(UserBan, conn)
end
- def new(conn, %{"username" => username}) do
- changeset = Bans.change_user(%UserBan{username: username})
- render(conn, "new.html", title: "New User Ban", changeset: changeset)
+ def new(conn, %{"user_id" => id}) do
+ target_user = Users.get_user!(id)
+ changeset = Bans.change_user(Ecto.build_assoc(target_user, :bans))
+
+ render(conn, "new.html",
+ title: "New User Ban",
+ target_user: target_user,
+ changeset: changeset
+ )
end
def new(conn, _params) do
- changeset = Bans.change_user(%UserBan{})
- render(conn, "new.html", title: "New User Ban", changeset: changeset)
+ conn
+ |> put_flash(:error, "Must create ban on user.")
+ |> redirect(to: ~p"/admin/user_bans")
end
def create(conn, %{"user" => user_ban_params}) do
diff --git a/lib/philomena_web/controllers/api/json/search/comment_controller.ex b/lib/philomena_web/controllers/api/json/search/comment_controller.ex
index 5dbe5e4c..6942a4ff 100644
--- a/lib/philomena_web/controllers/api/json/search/comment_controller.ex
+++ b/lib/philomena_web/controllers/api/json/search/comment_controller.ex
@@ -10,7 +10,7 @@ defmodule PhilomenaWeb.Api.Json.Search.CommentController do
user = conn.assigns.current_user
filter = conn.assigns.current_filter
- case Query.compile(user, params["q"] || "") do
+ case Query.compile(params["q"], user: user) do
{:ok, query} ->
comments =
Comment
diff --git a/lib/philomena_web/controllers/api/json/search/filter_controller.ex b/lib/philomena_web/controllers/api/json/search/filter_controller.ex
index 7b402065..7c4f81b5 100644
--- a/lib/philomena_web/controllers/api/json/search/filter_controller.ex
+++ b/lib/philomena_web/controllers/api/json/search/filter_controller.ex
@@ -9,7 +9,7 @@ defmodule PhilomenaWeb.Api.Json.Search.FilterController do
def index(conn, params) do
user = conn.assigns.current_user
- case Query.compile(user, params["q"] || "") do
+ case Query.compile(params["q"], user: user) do
{:ok, query} ->
filters =
Filter
diff --git a/lib/philomena_web/controllers/api/json/search/gallery_controller.ex b/lib/philomena_web/controllers/api/json/search/gallery_controller.ex
index 8b2f247b..e1b999bb 100644
--- a/lib/philomena_web/controllers/api/json/search/gallery_controller.ex
+++ b/lib/philomena_web/controllers/api/json/search/gallery_controller.ex
@@ -7,7 +7,7 @@ defmodule PhilomenaWeb.Api.Json.Search.GalleryController do
import Ecto.Query
def index(conn, params) do
- case Query.compile(params["q"] || "") do
+ case Query.compile(params["q"]) do
{:ok, query} ->
galleries =
Gallery
diff --git a/lib/philomena_web/controllers/api/json/search/post_controller.ex b/lib/philomena_web/controllers/api/json/search/post_controller.ex
index 919a5b13..c305de12 100644
--- a/lib/philomena_web/controllers/api/json/search/post_controller.ex
+++ b/lib/philomena_web/controllers/api/json/search/post_controller.ex
@@ -9,7 +9,7 @@ defmodule PhilomenaWeb.Api.Json.Search.PostController do
def index(conn, params) do
user = conn.assigns.current_user
- case Query.compile(user, params["q"] || "") do
+ case Query.compile(params["q"], user: user) do
{:ok, query} ->
posts =
Post
diff --git a/lib/philomena_web/controllers/api/json/search/reverse_controller.ex b/lib/philomena_web/controllers/api/json/search/reverse_controller.ex
index e1cf4d7c..4abe7560 100644
--- a/lib/philomena_web/controllers/api/json/search/reverse_controller.ex
+++ b/lib/philomena_web/controllers/api/json/search/reverse_controller.ex
@@ -1,7 +1,7 @@
defmodule PhilomenaWeb.Api.Json.Search.ReverseController do
use PhilomenaWeb, :controller
- alias PhilomenaWeb.ImageReverse
+ alias Philomena.DuplicateReports
alias Philomena.Interactions
plug PhilomenaWeb.ScraperCachePlug
@@ -10,15 +10,27 @@ defmodule PhilomenaWeb.Api.Json.Search.ReverseController do
def create(conn, %{"image" => image_params}) do
user = conn.assigns.current_user
- images =
+ {images, total} =
image_params
|> Map.put("distance", conn.params["distance"])
- |> ImageReverse.images()
+ |> Map.put("limit", conn.params["limit"])
+ |> DuplicateReports.execute_search_query()
+ |> case do
+ {:ok, images} ->
+ {images, images.total_entries}
+
+ {:error, _changeset} ->
+ {[], 0}
+ end
interactions = Interactions.user_interactions(images, user)
conn
|> put_view(PhilomenaWeb.Api.Json.ImageView)
- |> render("index.json", images: images, total: length(images), interactions: interactions)
+ |> render("index.json",
+ images: images,
+ total: total,
+ interactions: interactions
+ )
end
end
diff --git a/lib/philomena_web/controllers/api/json/search/tag_controller.ex b/lib/philomena_web/controllers/api/json/search/tag_controller.ex
index 8cdaf7f4..b860cf46 100644
--- a/lib/philomena_web/controllers/api/json/search/tag_controller.ex
+++ b/lib/philomena_web/controllers/api/json/search/tag_controller.ex
@@ -7,7 +7,7 @@ defmodule PhilomenaWeb.Api.Json.Search.TagController do
import Ecto.Query
def index(conn, params) do
- case Query.compile(params["q"] || "") do
+ case Query.compile(params["q"]) do
{:ok, query} ->
tags =
Tag
diff --git a/lib/philomena_web/controllers/channel/read_controller.ex b/lib/philomena_web/controllers/channel/read_controller.ex
index 415c6b57..91787262 100644
--- a/lib/philomena_web/controllers/channel/read_controller.ex
+++ b/lib/philomena_web/controllers/channel/read_controller.ex
@@ -11,7 +11,7 @@ defmodule PhilomenaWeb.Channel.ReadController do
channel = conn.assigns.channel
user = conn.assigns.current_user
- Channels.clear_notification(channel, user)
+ Channels.clear_channel_notification(channel, user)
send_resp(conn, :ok, "")
end
diff --git a/lib/philomena_web/controllers/channel_controller.ex b/lib/philomena_web/controllers/channel_controller.ex
index 6d88d257..a548dda9 100644
--- a/lib/philomena_web/controllers/channel_controller.ex
+++ b/lib/philomena_web/controllers/channel_controller.ex
@@ -37,7 +37,7 @@ defmodule PhilomenaWeb.ChannelController do
channel = conn.assigns.channel
user = conn.assigns.current_user
- if user, do: Channels.clear_notification(channel, user)
+ Channels.clear_channel_notification(channel, user)
redirect(conn, external: channel_url(channel))
end
diff --git a/lib/philomena_web/controllers/comment_controller.ex b/lib/philomena_web/controllers/comment_controller.ex
index 99b14f25..d64f3e16 100644
--- a/lib/philomena_web/controllers/comment_controller.ex
+++ b/lib/philomena_web/controllers/comment_controller.ex
@@ -13,8 +13,8 @@ defmodule PhilomenaWeb.CommentController do
conn = Map.put(conn, :params, params)
user = conn.assigns.current_user
- user
- |> Query.compile(cq)
+ cq
+ |> Query.compile(user: user)
|> render_index(conn, user)
end
diff --git a/lib/philomena_web/controllers/conversation/message/approve_controller.ex b/lib/philomena_web/controllers/conversation/message/approve_controller.ex
index 1693f432..fde13b1a 100644
--- a/lib/philomena_web/controllers/conversation/message/approve_controller.ex
+++ b/lib/philomena_web/controllers/conversation/message/approve_controller.ex
@@ -16,7 +16,7 @@ defmodule PhilomenaWeb.Conversation.Message.ApproveController do
message = conn.assigns.message
{:ok, _message} =
- Conversations.approve_conversation_message(message, conn.assigns.current_user)
+ Conversations.approve_message(message, conn.assigns.current_user)
conn
|> put_flash(:info, "Conversation message approved.")
diff --git a/lib/philomena_web/controllers/conversation/message_controller.ex b/lib/philomena_web/controllers/conversation/message_controller.ex
index 8bd44940..f6f7fdd1 100644
--- a/lib/philomena_web/controllers/conversation/message_controller.ex
+++ b/lib/philomena_web/controllers/conversation/message_controller.ex
@@ -1,10 +1,8 @@
defmodule PhilomenaWeb.Conversation.MessageController do
use PhilomenaWeb, :controller
- alias Philomena.Conversations.{Conversation, Message}
+ alias Philomena.Conversations.Conversation
alias Philomena.Conversations
- alias Philomena.Repo
- import Ecto.Query
plug PhilomenaWeb.FilterBannedUsersPlug
plug PhilomenaWeb.CanaryMapPlug, create: :show
@@ -15,24 +13,16 @@ defmodule PhilomenaWeb.Conversation.MessageController do
id_field: "slug",
persisted: true
+ @page_size 25
+
def create(conn, %{"message" => message_params}) do
conversation = conn.assigns.conversation
user = conn.assigns.current_user
case Conversations.create_message(conversation, user, message_params) do
- {:ok, %{message: message}} ->
- if not message.approved do
- Conversations.report_non_approved(message.conversation_id)
- end
-
- count =
- Message
- |> where(conversation_id: ^conversation.id)
- |> Repo.aggregate(:count, :id)
-
- page =
- Float.ceil(count / 25)
- |> round()
+ {:ok, _message} ->
+ count = Conversations.count_messages(conversation)
+ page = div(count + @page_size - 1, @page_size)
conn
|> put_flash(:info, "Message successfully sent.")
diff --git a/lib/philomena_web/controllers/conversation/report_controller.ex b/lib/philomena_web/controllers/conversation/report_controller.ex
index dab81482..0f5736ca 100644
--- a/lib/philomena_web/controllers/conversation/report_controller.ex
+++ b/lib/philomena_web/controllers/conversation/report_controller.ex
@@ -42,6 +42,6 @@ defmodule PhilomenaWeb.Conversation.ReportController do
conversation = conn.assigns.conversation
action = ~p"/conversations/#{conversation}/reports"
- ReportController.create(conn, action, conversation, "Conversation", params)
+ ReportController.create(conn, action, "Conversation", conversation, params)
end
end
diff --git a/lib/philomena_web/controllers/conversation_controller.ex b/lib/philomena_web/controllers/conversation_controller.ex
index 12784b42..0e4abdf0 100644
--- a/lib/philomena_web/controllers/conversation_controller.ex
+++ b/lib/philomena_web/controllers/conversation_controller.ex
@@ -4,8 +4,6 @@ defmodule PhilomenaWeb.ConversationController do
alias PhilomenaWeb.NotificationCountPlug
alias Philomena.{Conversations, Conversations.Conversation, Conversations.Message}
alias PhilomenaWeb.MarkdownRenderer
- alias Philomena.Repo
- import Ecto.Query
plug PhilomenaWeb.FilterBannedUsersPlug when action in [:new, :create]
@@ -19,42 +17,17 @@ defmodule PhilomenaWeb.ConversationController do
only: :show,
preload: [:to, :from]
- def index(conn, %{"with" => partner}) do
+ def index(conn, params) do
user = conn.assigns.current_user
- Conversation
- |> where(
- [c],
- (c.from_id == ^user.id and c.to_id == ^partner and not c.from_hidden) or
- (c.to_id == ^user.id and c.from_id == ^partner and not c.to_hidden)
- )
- |> load_conversations(conn)
- end
-
- def index(conn, _params) do
- user = conn.assigns.current_user
-
- Conversation
- |> where(
- [c],
- (c.from_id == ^user.id and not c.from_hidden) or (c.to_id == ^user.id and not c.to_hidden)
- )
- |> load_conversations(conn)
- end
-
- defp load_conversations(queryable, conn) do
conversations =
- queryable
- |> join(
- :inner_lateral,
- [c],
- _ in fragment("SELECT COUNT(*) FROM messages m WHERE m.conversation_id = ?", c.id),
- on: true
- )
- |> order_by(desc: :last_message_at)
- |> preload([:to, :from])
- |> select([c, cnt], {c, cnt.count})
- |> Repo.paginate(conn.assigns.scrivener)
+ case params do
+ %{"with" => partner_id} ->
+ Conversations.list_conversations_with(partner_id, user, conn.assigns.scrivener)
+
+ _ ->
+ Conversations.list_conversations(user, conn.assigns.scrivener)
+ end
render(conn, "index.html", title: "Conversations", conversations: conversations)
end
@@ -62,27 +35,17 @@ defmodule PhilomenaWeb.ConversationController do
def show(conn, _params) do
conversation = conn.assigns.conversation
user = conn.assigns.current_user
- pref = load_direction(user)
messages =
- Message
- |> where(conversation_id: ^conversation.id)
- |> order_by([{^pref, :created_at}])
- |> preload([:from])
- |> Repo.paginate(conn.assigns.scrivener)
+ Conversations.list_messages(
+ conversation,
+ user,
+ &MarkdownRenderer.render_collection(&1, conn),
+ conn.assigns.scrivener
+ )
- rendered =
- messages.entries
- |> MarkdownRenderer.render_collection(conn)
-
- messages = %{messages | entries: Enum.zip(messages.entries, rendered)}
-
- changeset =
- %Message{}
- |> Conversations.change_message()
-
- conversation
- |> Conversations.mark_conversation_read(user)
+ changeset = Conversations.change_message(%Message{})
+ Conversations.mark_conversation_read(conversation, user)
# Update the conversation ticker in the header
conn = NotificationCountPlug.call(conn)
@@ -96,9 +59,10 @@ defmodule PhilomenaWeb.ConversationController do
end
def new(conn, params) do
- changeset =
+ conversation =
%Conversation{recipient: params["recipient"], messages: [%Message{}]}
- |> Conversations.change_conversation()
+
+ changeset = Conversations.change_conversation(conversation)
render(conn, "new.html", title: "New Conversation", changeset: changeset)
end
@@ -108,21 +72,12 @@ defmodule PhilomenaWeb.ConversationController do
case Conversations.create_conversation(user, conversation_params) do
{:ok, conversation} ->
- if not hd(conversation.messages).approved do
- Conversations.report_non_approved(conversation.id)
- Conversations.set_as_read(conversation)
- end
-
conn
|> put_flash(:info, "Conversation successfully created.")
|> redirect(to: ~p"/conversations/#{conversation}")
{:error, changeset} ->
- conn
- |> render("new.html", changeset: changeset)
+ render(conn, "new.html", changeset: changeset)
end
end
-
- defp load_direction(%{messages_newest_first: false}), do: :asc
- defp load_direction(_user), do: :desc
end
diff --git a/lib/philomena_web/controllers/dnp_entry_controller.ex b/lib/philomena_web/controllers/dnp_entry_controller.ex
index d2e675b2..49ca7e94 100644
--- a/lib/philomena_web/controllers/dnp_entry_controller.ex
+++ b/lib/philomena_web/controllers/dnp_entry_controller.ex
@@ -6,7 +6,7 @@ defmodule PhilomenaWeb.DnpEntryController do
alias Philomena.DnpEntries
alias Philomena.Tags.Tag
alias Philomena.ModNotes.ModNote
- alias Philomena.Polymorphic
+ alias Philomena.ModNotes
alias Philomena.Repo
import Ecto.Query
@@ -154,19 +154,8 @@ defmodule PhilomenaWeb.DnpEntryController do
true ->
dnp_entry = conn.assigns.dnp_entry
- mod_notes =
- ModNote
- |> where(notable_type: "DnpEntry", notable_id: ^dnp_entry.id)
- |> order_by(desc: :id)
- |> preload(:moderator)
- |> Repo.all()
- |> Polymorphic.load_polymorphic(notable: [notable_id: :notable_type])
-
- mod_notes =
- mod_notes
- |> MarkdownRenderer.render_collection(conn)
- |> Enum.zip(mod_notes)
-
+ renderer = &MarkdownRenderer.render_collection(&1, conn)
+ mod_notes = ModNotes.list_all_mod_notes_by_type_and_id("DnpEntry", dnp_entry.id, renderer)
assign(conn, :mod_notes, mod_notes)
_false ->
diff --git a/lib/philomena_web/controllers/filter_controller.ex b/lib/philomena_web/controllers/filter_controller.ex
index 37b1d5ec..5fd7da68 100644
--- a/lib/philomena_web/controllers/filter_controller.ex
+++ b/lib/philomena_web/controllers/filter_controller.ex
@@ -13,8 +13,8 @@ defmodule PhilomenaWeb.FilterController do
def index(conn, %{"fq" => fq}) do
user = conn.assigns.current_user
- user
- |> Query.compile(fq)
+ fq
+ |> Query.compile(user: user)
|> render_index(conn, user)
end
diff --git a/lib/philomena_web/controllers/forum/read_controller.ex b/lib/philomena_web/controllers/forum/read_controller.ex
deleted file mode 100644
index cca7ee69..00000000
--- a/lib/philomena_web/controllers/forum/read_controller.ex
+++ /dev/null
@@ -1,22 +0,0 @@
-defmodule PhilomenaWeb.Forum.ReadController do
- import Plug.Conn
- use PhilomenaWeb, :controller
-
- alias Philomena.Forums.Forum
- alias Philomena.Forums
-
- plug :load_resource,
- model: Forum,
- id_name: "forum_id",
- id_field: "short_name",
- persisted: true
-
- def create(conn, _params) do
- forum = conn.assigns.forum
- user = conn.assigns.current_user
-
- Forums.clear_notification(forum, user)
-
- send_resp(conn, :ok, "")
- end
-end
diff --git a/lib/philomena_web/controllers/gallery/read_controller.ex b/lib/philomena_web/controllers/gallery/read_controller.ex
index eee4e3d0..ffe1eb55 100644
--- a/lib/philomena_web/controllers/gallery/read_controller.ex
+++ b/lib/philomena_web/controllers/gallery/read_controller.ex
@@ -11,7 +11,7 @@ defmodule PhilomenaWeb.Gallery.ReadController do
gallery = conn.assigns.gallery
user = conn.assigns.current_user
- Galleries.clear_notification(gallery, user)
+ Galleries.clear_gallery_notification(gallery, user)
send_resp(conn, :ok, "")
end
diff --git a/lib/philomena_web/controllers/gallery/report_controller.ex b/lib/philomena_web/controllers/gallery/report_controller.ex
index 3d4b5fd5..c5d8b0a2 100644
--- a/lib/philomena_web/controllers/gallery/report_controller.ex
+++ b/lib/philomena_web/controllers/gallery/report_controller.ex
@@ -41,6 +41,6 @@ defmodule PhilomenaWeb.Gallery.ReportController do
gallery = conn.assigns.gallery
action = ~p"/galleries/#{gallery}/reports"
- ReportController.create(conn, action, gallery, "Gallery", params)
+ ReportController.create(conn, action, "Gallery", gallery, params)
end
end
diff --git a/lib/philomena_web/controllers/gallery_controller.ex b/lib/philomena_web/controllers/gallery_controller.ex
index 64a020e0..0f1f5a71 100644
--- a/lib/philomena_web/controllers/gallery_controller.ex
+++ b/lib/philomena_web/controllers/gallery_controller.ex
@@ -80,7 +80,7 @@ defmodule PhilomenaWeb.GalleryController do
gallery_json = Jason.encode!(Enum.map(gallery_images, &elem(&1, 0).id))
- Galleries.clear_notification(gallery, user)
+ Galleries.clear_gallery_notification(gallery, user)
conn
|> NotificationCountPlug.call([])
diff --git a/lib/philomena_web/controllers/image/comment/report_controller.ex b/lib/philomena_web/controllers/image/comment/report_controller.ex
index d957abbd..cb2f0b98 100644
--- a/lib/philomena_web/controllers/image/comment/report_controller.ex
+++ b/lib/philomena_web/controllers/image/comment/report_controller.ex
@@ -44,6 +44,6 @@ defmodule PhilomenaWeb.Image.Comment.ReportController do
comment = conn.assigns.comment
action = ~p"/images/#{comment.image}/comments/#{comment}/reports"
- ReportController.create(conn, action, comment, "Comment", params)
+ ReportController.create(conn, action, "Comment", comment, params)
end
end
diff --git a/lib/philomena_web/controllers/image/comment_controller.ex b/lib/philomena_web/controllers/image/comment_controller.ex
index ca0ff9ad..86fb712d 100644
--- a/lib/philomena_web/controllers/image/comment_controller.ex
+++ b/lib/philomena_web/controllers/image/comment_controller.ex
@@ -82,7 +82,6 @@ defmodule PhilomenaWeb.Image.CommentController do
Images.reindex_image(conn.assigns.image)
if comment.approved do
- Comments.notify_comment(comment)
UserStatistics.inc_stat(conn.assigns.current_user, :comments_posted)
else
Comments.report_non_approved(comment)
diff --git a/lib/philomena_web/controllers/image/navigate_controller.ex b/lib/philomena_web/controllers/image/navigate_controller.ex
index 9cb61d48..13facfae 100644
--- a/lib/philomena_web/controllers/image/navigate_controller.ex
+++ b/lib/philomena_web/controllers/image/navigate_controller.ex
@@ -54,7 +54,10 @@ defmodule PhilomenaWeb.Image.NavigateController do
defp compile_query(conn) do
user = conn.assigns.current_user
- {:ok, query} = Query.compile(user, match_all_if_blank(conn.params["q"]))
+ {:ok, query} =
+ conn.params["q"]
+ |> match_all_if_blank()
+ |> Query.compile(user: user)
query
end
diff --git a/lib/philomena_web/controllers/image/read_controller.ex b/lib/philomena_web/controllers/image/read_controller.ex
index 965b7fdc..c1715a66 100644
--- a/lib/philomena_web/controllers/image/read_controller.ex
+++ b/lib/philomena_web/controllers/image/read_controller.ex
@@ -11,7 +11,7 @@ defmodule PhilomenaWeb.Image.ReadController do
image = conn.assigns.image
user = conn.assigns.current_user
- Images.clear_notification(image, user)
+ Images.clear_image_notification(image, user)
send_resp(conn, :ok, "")
end
diff --git a/lib/philomena_web/controllers/image/report_controller.ex b/lib/philomena_web/controllers/image/report_controller.ex
index 6956832e..c00cee3d 100644
--- a/lib/philomena_web/controllers/image/report_controller.ex
+++ b/lib/philomena_web/controllers/image/report_controller.ex
@@ -41,6 +41,6 @@ defmodule PhilomenaWeb.Image.ReportController do
image = conn.assigns.image
action = ~p"/images/#{image}/reports"
- ReportController.create(conn, action, image, "Image", params)
+ ReportController.create(conn, action, "Image", image, params)
end
end
diff --git a/lib/philomena_web/controllers/image/tag_controller.ex b/lib/philomena_web/controllers/image/tag_controller.ex
index 2bae9731..468864c9 100644
--- a/lib/philomena_web/controllers/image/tag_controller.ex
+++ b/lib/philomena_web/controllers/image/tag_controller.ex
@@ -8,6 +8,7 @@ defmodule PhilomenaWeb.Image.TagController do
alias Philomena.Images
alias Philomena.Tags
alias Philomena.Repo
+ alias Plug.Conn
import Ecto.Query
plug PhilomenaWeb.LimitPlug,
@@ -88,6 +89,18 @@ defmodule PhilomenaWeb.Image.TagController do
image: image,
changeset: changeset
)
+
+ {:error, :check_limits, _error, _} ->
+ conn
+ |> put_flash(:error, "Too many tags changed. Change fewer tags or try again later.")
+ |> Conn.send_resp(:multiple_choices, "")
+ |> Conn.halt()
+
+ _err ->
+ conn
+ |> put_flash(:error, "Failed to update tags!")
+ |> Conn.send_resp(:multiple_choices, "")
+ |> Conn.halt()
end
end
end
diff --git a/lib/philomena_web/controllers/image_controller.ex b/lib/philomena_web/controllers/image_controller.ex
index ab51816c..c66c2fe7 100644
--- a/lib/philomena_web/controllers/image_controller.ex
+++ b/lib/philomena_web/controllers/image_controller.ex
@@ -56,7 +56,7 @@ defmodule PhilomenaWeb.ImageController do
image = conn.assigns.image
user = conn.assigns.current_user
- Images.clear_notification(image, user)
+ Images.clear_image_notification(image, user)
# Update the notification ticker in the header
conn = NotificationCountPlug.call(conn)
diff --git a/lib/philomena_web/controllers/moderation_log_controller.ex b/lib/philomena_web/controllers/moderation_log_controller.ex
index 3d9e7699..a3f202b1 100644
--- a/lib/philomena_web/controllers/moderation_log_controller.ex
+++ b/lib/philomena_web/controllers/moderation_log_controller.ex
@@ -9,7 +9,7 @@ defmodule PhilomenaWeb.ModerationLogController do
preload: [:user]
def index(conn, _params) do
- moderation_logs = ModerationLogs.list_moderation_logs(conn)
+ moderation_logs = ModerationLogs.list_moderation_logs(conn.assigns.scrivener)
render(conn, "index.html", title: "Moderation Logs", moderation_logs: moderation_logs)
end
end
diff --git a/lib/philomena_web/controllers/notification/category_controller.ex b/lib/philomena_web/controllers/notification/category_controller.ex
new file mode 100644
index 00000000..c050c4e9
--- /dev/null
+++ b/lib/philomena_web/controllers/notification/category_controller.ex
@@ -0,0 +1,33 @@
+defmodule PhilomenaWeb.Notification.CategoryController do
+ use PhilomenaWeb, :controller
+
+ alias Philomena.Notifications
+
+ def show(conn, params) do
+ category_param = category(params)
+
+ notifications =
+ Notifications.unread_notifications_for_user_and_category(
+ conn.assigns.current_user,
+ category_param,
+ conn.assigns.scrivener
+ )
+
+ render(conn, "show.html",
+ title: "Notification Area",
+ notifications: notifications,
+ category: category_param
+ )
+ end
+
+ defp category(params) do
+ case params["id"] do
+ "channel_live" -> :channel_live
+ "gallery_image" -> :gallery_image
+ "image_comment" -> :image_comment
+ "image_merge" -> :image_merge
+ "forum_topic" -> :forum_topic
+ _ -> :forum_post
+ end
+ end
+end
diff --git a/lib/philomena_web/controllers/notification_controller.ex b/lib/philomena_web/controllers/notification_controller.ex
index 170d504b..158fc5f3 100644
--- a/lib/philomena_web/controllers/notification_controller.ex
+++ b/lib/philomena_web/controllers/notification_controller.ex
@@ -1,34 +1,15 @@
defmodule PhilomenaWeb.NotificationController do
use PhilomenaWeb, :controller
- alias Philomena.Notifications.{UnreadNotification, Notification}
- alias Philomena.Polymorphic
- alias Philomena.Repo
- import Ecto.Query
+ alias Philomena.Notifications
def index(conn, _params) do
- user = conn.assigns.current_user
-
notifications =
- from n in Notification,
- join: un in UnreadNotification,
- on: un.notification_id == n.id,
- where: un.user_id == ^user.id
-
- notifications =
- notifications
- |> order_by(desc: :updated_at)
- |> Repo.paginate(conn.assigns.scrivener)
-
- entries =
- notifications.entries
- |> Polymorphic.load_polymorphic(
- actor: [actor_id: :actor_type],
- actor_child: [actor_child_id: :actor_child_type]
+ Notifications.unread_notifications_for_user(
+ conn.assigns.current_user,
+ page_size: 10
)
- notifications = %{notifications | entries: entries}
-
render(conn, "index.html", title: "Notification Area", notifications: notifications)
end
end
diff --git a/lib/philomena_web/controllers/post_controller.ex b/lib/philomena_web/controllers/post_controller.ex
index 17b8fcd5..6f00ff7c 100644
--- a/lib/philomena_web/controllers/post_controller.ex
+++ b/lib/philomena_web/controllers/post_controller.ex
@@ -13,8 +13,8 @@ defmodule PhilomenaWeb.PostController do
conn = Map.put(conn, :params, params)
user = conn.assigns.current_user
- user
- |> Query.compile(pq)
+ pq
+ |> Query.compile(user: user)
|> render_index(conn, user)
end
diff --git a/lib/philomena_web/controllers/profile/commission/report_controller.ex b/lib/philomena_web/controllers/profile/commission/report_controller.ex
index 0ad943ef..9fe29308 100644
--- a/lib/philomena_web/controllers/profile/commission/report_controller.ex
+++ b/lib/philomena_web/controllers/profile/commission/report_controller.ex
@@ -53,7 +53,7 @@ defmodule PhilomenaWeb.Profile.Commission.ReportController do
commission = conn.assigns.user.commission
action = ~p"/profiles/#{user}/commission/reports"
- ReportController.create(conn, action, commission, "Commission", params)
+ ReportController.create(conn, action, "Commission", commission, params)
end
defp ensure_commission(conn, _opts) do
diff --git a/lib/philomena_web/controllers/profile/detail_controller.ex b/lib/philomena_web/controllers/profile/detail_controller.ex
index 0461c31e..42681d03 100644
--- a/lib/philomena_web/controllers/profile/detail_controller.ex
+++ b/lib/philomena_web/controllers/profile/detail_controller.ex
@@ -2,9 +2,8 @@ defmodule PhilomenaWeb.Profile.DetailController do
use PhilomenaWeb, :controller
alias Philomena.UserNameChanges.UserNameChange
- alias Philomena.ModNotes.ModNote
+ alias Philomena.ModNotes
alias PhilomenaWeb.MarkdownRenderer
- alias Philomena.Polymorphic
alias Philomena.Users.User
alias Philomena.Repo
import Ecto.Query
@@ -20,18 +19,8 @@ defmodule PhilomenaWeb.Profile.DetailController do
def index(conn, _params) do
user = conn.assigns.user
- mod_notes =
- ModNote
- |> where(notable_type: "User", notable_id: ^user.id)
- |> order_by(desc: :id)
- |> preload(:moderator)
- |> Repo.all()
- |> Polymorphic.load_polymorphic(notable: [notable_id: :notable_type])
-
- mod_notes =
- mod_notes
- |> MarkdownRenderer.render_collection(conn)
- |> Enum.zip(mod_notes)
+ renderer = &MarkdownRenderer.render_collection(&1, conn)
+ mod_notes = ModNotes.list_all_mod_notes_by_type_and_id("User", user.id, renderer)
name_changes =
UserNameChange
diff --git a/lib/philomena_web/controllers/profile/report_controller.ex b/lib/philomena_web/controllers/profile/report_controller.ex
index 80a68895..b57c3a64 100644
--- a/lib/philomena_web/controllers/profile/report_controller.ex
+++ b/lib/philomena_web/controllers/profile/report_controller.ex
@@ -41,6 +41,6 @@ defmodule PhilomenaWeb.Profile.ReportController do
user = conn.assigns.user
action = ~p"/profiles/#{user}/reports"
- ReportController.create(conn, action, user, "User", params)
+ ReportController.create(conn, action, "User", user, params)
end
end
diff --git a/lib/philomena_web/controllers/profile_controller.ex b/lib/philomena_web/controllers/profile_controller.ex
index 75f476d4..d3e375f4 100644
--- a/lib/philomena_web/controllers/profile_controller.ex
+++ b/lib/philomena_web/controllers/profile_controller.ex
@@ -15,7 +15,7 @@ defmodule PhilomenaWeb.ProfileController do
alias Philomena.UserIps.UserIp
alias Philomena.UserFingerprints.UserFingerprint
alias Philomena.ModNotes.ModNote
- alias Philomena.Polymorphic
+ alias Philomena.ModNotes
alias Philomena.Images.Image
alias Philomena.Repo
import Ecto.Query
@@ -125,8 +125,12 @@ defmodule PhilomenaWeb.ProfileController do
preload(Image, [:sources, tags: :aliases]),
preload(Image, [:sources, tags: :aliases]),
preload(Image, [:sources, tags: :aliases]),
- preload(Comment, user: [awards: :badge], image: [:sources, tags: :aliases]),
- preload(Post, user: [awards: :badge], topic: :forum)
+ preload(Comment, [
+ :deleted_by,
+ user: [awards: :badge],
+ image: [:sources, tags: :aliases]
+ ]),
+ preload(Post, [:deleted_by, user: [awards: :badge], topic: :forum])
]
)
@@ -275,21 +279,10 @@ defmodule PhilomenaWeb.ProfileController do
defp set_mod_notes(conn, _opts) do
case Canada.Can.can?(conn.assigns.current_user, :index, ModNote) do
true ->
+ renderer = &MarkdownRenderer.render_collection(&1, conn)
user = conn.assigns.user
- mod_notes =
- ModNote
- |> where(notable_type: "User", notable_id: ^user.id)
- |> order_by(desc: :id)
- |> preload(:moderator)
- |> Repo.all()
- |> Polymorphic.load_polymorphic(notable: [notable_id: :notable_type])
-
- mod_notes =
- mod_notes
- |> MarkdownRenderer.render_collection(conn)
- |> Enum.zip(mod_notes)
-
+ mod_notes = ModNotes.list_all_mod_notes_by_type_and_id("User", user.id, renderer)
assign(conn, :mod_notes, mod_notes)
_false ->
diff --git a/lib/philomena_web/controllers/report_controller.ex b/lib/philomena_web/controllers/report_controller.ex
index c3e9ff8c..582e811c 100644
--- a/lib/philomena_web/controllers/report_controller.ex
+++ b/lib/philomena_web/controllers/report_controller.ex
@@ -33,7 +33,7 @@ defmodule PhilomenaWeb.ReportController do
# plug PhilomenaWeb.CheckCaptchaPlug when action in [:create]
# plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true
- def create(conn, action, reportable, reportable_type, %{"report" => report_params}) do
+ def create(conn, action, reportable_type, reportable, %{"report" => report_params}) do
attribution = conn.assigns.attributes
case too_many_reports?(conn) do
@@ -46,7 +46,7 @@ defmodule PhilomenaWeb.ReportController do
|> redirect(to: "/")
_falsy ->
- case Reports.create_report(reportable.id, reportable_type, attribution, report_params) do
+ case Reports.create_report({reportable_type, reportable.id}, attribution, report_params) do
{:ok, _report} ->
conn
|> put_flash(
diff --git a/lib/philomena_web/controllers/search/reverse_controller.ex b/lib/philomena_web/controllers/search/reverse_controller.ex
index 54c52dac..0938642a 100644
--- a/lib/philomena_web/controllers/search/reverse_controller.ex
+++ b/lib/philomena_web/controllers/search/reverse_controller.ex
@@ -1,7 +1,9 @@
defmodule PhilomenaWeb.Search.ReverseController do
use PhilomenaWeb, :controller
- alias PhilomenaWeb.ImageReverse
+ alias Philomena.DuplicateReports.SearchQuery
+ alias Philomena.DuplicateReports
+ alias Philomena.Interactions
plug PhilomenaWeb.ScraperCachePlug
plug PhilomenaWeb.ScraperPlug, params_key: "image", params_name: "image"
@@ -12,12 +14,37 @@ defmodule PhilomenaWeb.Search.ReverseController do
def create(conn, %{"image" => image_params})
when is_map(image_params) and image_params != %{} do
- images = ImageReverse.images(image_params)
+ case DuplicateReports.execute_search_query(image_params) do
+ {:ok, images} ->
+ changeset = DuplicateReports.change_search_query(%SearchQuery{})
+ interactions = Interactions.user_interactions(images, conn.assigns.current_user)
- render(conn, "index.html", title: "Reverse Search", images: images)
+ render(conn, "index.html",
+ title: "Reverse Search",
+ layout_class: "layout--wide",
+ images: images,
+ changeset: changeset,
+ interactions: interactions
+ )
+
+ {:error, changeset} ->
+ render(conn, "index.html",
+ title: "Reverse Search",
+ layout_class: "layout--wide",
+ images: nil,
+ changeset: changeset
+ )
+ end
end
def create(conn, _params) do
- render(conn, "index.html", title: "Reverse Search", images: nil)
+ changeset = DuplicateReports.change_search_query(%SearchQuery{})
+
+ render(conn, "index.html",
+ title: "Reverse Search",
+ layout_class: "layout--wide",
+ images: nil,
+ changeset: changeset
+ )
end
end
diff --git a/lib/philomena_web/controllers/tag_change/full_revert_controller.ex b/lib/philomena_web/controllers/tag_change/full_revert_controller.ex
index 3ebfd471..2f095715 100644
--- a/lib/philomena_web/controllers/tag_change/full_revert_controller.ex
+++ b/lib/philomena_web/controllers/tag_change/full_revert_controller.ex
@@ -11,8 +11,6 @@ defmodule PhilomenaWeb.TagChange.FullRevertController do
attributes = %{
ip: to_string(attributes[:ip]),
fingerprint: attributes[:fingerprint],
- referrer: attributes[:referrer],
- user_agent: attributes[:referrer],
user_id: attributes[:user].id,
batch_size: attributes[:batch_size] || 100
}
diff --git a/lib/philomena_web/controllers/tag_change/revert_controller.ex b/lib/philomena_web/controllers/tag_change/revert_controller.ex
index fba63fee..84782304 100644
--- a/lib/philomena_web/controllers/tag_change/revert_controller.ex
+++ b/lib/philomena_web/controllers/tag_change/revert_controller.ex
@@ -13,8 +13,6 @@ defmodule PhilomenaWeb.TagChange.RevertController do
attributes = %{
ip: attributes[:ip],
fingerprint: attributes[:fingerprint],
- referrer: attributes[:referrer],
- user_agent: attributes[:referrer],
user_id: attributes[:user].id
}
diff --git a/lib/philomena_web/controllers/tag_controller.ex b/lib/philomena_web/controllers/tag_controller.ex
index 46c6501b..755bc4d1 100644
--- a/lib/philomena_web/controllers/tag_controller.ex
+++ b/lib/philomena_web/controllers/tag_controller.ex
@@ -121,7 +121,7 @@ defmodule PhilomenaWeb.TagController do
|> String.trim()
|> String.downcase()
- case Images.Query.compile(nil, name) do
+ case Images.Query.compile(name) do
{:ok, %{term: %{"namespaced_tags.name" => ^name}}} ->
name
diff --git a/lib/philomena_web/controllers/topic/post/report_controller.ex b/lib/philomena_web/controllers/topic/post/report_controller.ex
index f09df511..b93ab225 100644
--- a/lib/philomena_web/controllers/topic/post/report_controller.ex
+++ b/lib/philomena_web/controllers/topic/post/report_controller.ex
@@ -42,6 +42,6 @@ defmodule PhilomenaWeb.Topic.Post.ReportController do
post = conn.assigns.post
action = ~p"/forums/#{topic.forum}/topics/#{topic}/posts/#{post}/reports"
- ReportController.create(conn, action, post, "Post", params)
+ ReportController.create(conn, action, "Post", post, params)
end
end
diff --git a/lib/philomena_web/controllers/topic/post_controller.ex b/lib/philomena_web/controllers/topic/post_controller.ex
index 7513f930..ca4c771e 100644
--- a/lib/philomena_web/controllers/topic/post_controller.ex
+++ b/lib/philomena_web/controllers/topic/post_controller.ex
@@ -36,7 +36,6 @@ defmodule PhilomenaWeb.Topic.PostController do
case Posts.create_post(topic, attributes, post_params) do
{:ok, %{post: post}} ->
if post.approved do
- Posts.notify_post(post)
UserStatistics.inc_stat(conn.assigns.current_user, :forum_posts)
else
Posts.report_non_approved(post)
diff --git a/lib/philomena_web/controllers/topic/read_controller.ex b/lib/philomena_web/controllers/topic/read_controller.ex
index 1c5c45b4..0ac80560 100644
--- a/lib/philomena_web/controllers/topic/read_controller.ex
+++ b/lib/philomena_web/controllers/topic/read_controller.ex
@@ -16,7 +16,7 @@ defmodule PhilomenaWeb.Topic.ReadController do
def create(conn, _params) do
user = conn.assigns.current_user
- Topics.clear_notification(conn.assigns.topic, user)
+ Topics.clear_topic_notification(conn.assigns.topic, user)
send_resp(conn, :ok, "")
end
diff --git a/lib/philomena_web/controllers/topic_controller.ex b/lib/philomena_web/controllers/topic_controller.ex
index e88670a0..4d3099e8 100644
--- a/lib/philomena_web/controllers/topic_controller.ex
+++ b/lib/philomena_web/controllers/topic_controller.ex
@@ -3,7 +3,7 @@ defmodule PhilomenaWeb.TopicController do
alias PhilomenaWeb.NotificationCountPlug
alias Philomena.{Forums.Forum, Topics.Topic, Posts.Post, Polls.Poll, PollOptions.PollOption}
- alias Philomena.{Forums, Topics, Polls, Posts}
+ alias Philomena.{Topics, Polls, Posts}
alias Philomena.PollVotes
alias PhilomenaWeb.MarkdownRenderer
alias Philomena.Repo
@@ -34,8 +34,7 @@ defmodule PhilomenaWeb.TopicController do
user = conn.assigns.current_user
- Topics.clear_notification(topic, user)
- Forums.clear_notification(forum, user)
+ Topics.clear_topic_notification(topic, user)
# Update the notification ticker in the header
conn = NotificationCountPlug.call(conn)
@@ -112,7 +111,6 @@ defmodule PhilomenaWeb.TopicController do
case Topics.create_topic(forum, attributes, topic_params) do
{:ok, %{topic: topic}} ->
post = hd(topic.posts)
- Topics.notify_topic(topic, post)
if forum.access_level == "normal" do
PhilomenaWeb.Endpoint.broadcast!(
diff --git a/lib/philomena_web/image_loader.ex b/lib/philomena_web/image_loader.ex
index d2bc80ea..81271e05 100644
--- a/lib/philomena_web/image_loader.ex
+++ b/lib/philomena_web/image_loader.ex
@@ -11,7 +11,7 @@ defmodule PhilomenaWeb.ImageLoader do
def search_string(conn, search_string, options \\ []) do
user = conn.assigns.current_user
- with {:ok, tree} <- Query.compile(user, search_string) do
+ with {:ok, tree} <- Query.compile(search_string, user: user) do
{:ok, query(conn, tree, options)}
else
error ->
diff --git a/lib/philomena_web/image_reverse.ex b/lib/philomena_web/image_reverse.ex
deleted file mode 100644
index 161ebad3..00000000
--- a/lib/philomena_web/image_reverse.ex
+++ /dev/null
@@ -1,63 +0,0 @@
-defmodule PhilomenaWeb.ImageReverse do
- alias PhilomenaMedia.Analyzers
- alias PhilomenaMedia.Processors
- alias Philomena.DuplicateReports
- alias Philomena.Repo
- import Ecto.Query
-
- def images(image_params) do
- image_params
- |> Map.get("image")
- |> analyze()
- |> intensities()
- |> case do
- :error ->
- []
-
- {analysis, intensities} ->
- {width, height} = analysis.dimensions
- aspect = width / height
- dist = normalize_dist(image_params)
-
- DuplicateReports.duplicates_of(intensities, aspect, dist, dist)
- |> preload([:user, :intensity, [:sources, tags: :aliases]])
- |> Repo.all()
- end
- end
-
- defp analyze(%Plug.Upload{path: path}) do
- case Analyzers.analyze(path) do
- {:ok, analysis} -> {analysis, path}
- _ -> :error
- end
- end
-
- defp analyze(_upload), do: :error
-
- defp intensities(:error), do: :error
-
- defp intensities({analysis, path}) do
- {analysis, Processors.intensities(analysis, path)}
- end
-
- # The distance metric is taxicab distance, not Euclidean,
- # because this is more efficient to index.
- defp normalize_dist(%{"distance" => distance}) do
- distance
- |> parse_dist()
- |> max(0.01)
- |> min(1.0)
- end
-
- defp normalize_dist(_dist), do: 0.25
-
- defp parse_dist(dist) do
- case Decimal.parse(dist) do
- {value, _rest} ->
- Decimal.to_float(value)
-
- _ ->
- 0.0
- end
- end
-end
diff --git a/lib/philomena_web/plugs/admin_counters_plug.ex b/lib/philomena_web/plugs/admin_counters_plug.ex
index 9e01a559..bf271c0e 100644
--- a/lib/philomena_web/plugs/admin_counters_plug.ex
+++ b/lib/philomena_web/plugs/admin_counters_plug.ex
@@ -34,7 +34,7 @@ defmodule PhilomenaWeb.AdminCountersPlug do
defp maybe_assign_admin_metrics(conn, user, true) do
pending_approvals = Images.count_pending_approvals(user)
duplicate_reports = DuplicateReports.count_duplicate_reports(user)
- reports = Reports.count_reports(user)
+ reports = Reports.count_open_reports(user)
artist_links = ArtistLinks.count_artist_links(user)
dnps = DnpEntries.count_dnp_entries(user)
diff --git a/lib/philomena_web/plugs/filter_forced_users_plug.ex b/lib/philomena_web/plugs/filter_forced_users_plug.ex
index e28de969..d9881f96 100644
--- a/lib/philomena_web/plugs/filter_forced_users_plug.ex
+++ b/lib/philomena_web/plugs/filter_forced_users_plug.ex
@@ -6,7 +6,7 @@ defmodule PhilomenaWeb.FilterForcedUsersPlug do
import Phoenix.Controller
import Plug.Conn
- alias PhilomenaQuery.Parse.String, as: SearchString
+ alias PhilomenaQuery.Parse.String
alias PhilomenaQuery.Parse.Evaluator
alias Philomena.Images.Query
alias PhilomenaWeb.ImageView
@@ -53,7 +53,10 @@ defmodule PhilomenaWeb.FilterForcedUsersPlug do
end
defp compile_filter(user, search_string) do
- case Query.compile(user, SearchString.normalize(search_string)) do
+ search_string
+ |> String.normalize()
+ |> Query.compile(user: user)
+ |> case do
{:ok, query} -> query
_error -> %{match_all: %{}}
end
diff --git a/lib/philomena_web/plugs/image_filter_plug.ex b/lib/philomena_web/plugs/image_filter_plug.ex
index c8138d68..a6d46187 100644
--- a/lib/philomena_web/plugs/image_filter_plug.ex
+++ b/lib/philomena_web/plugs/image_filter_plug.ex
@@ -1,7 +1,6 @@
defmodule PhilomenaWeb.ImageFilterPlug do
import Plug.Conn
- import PhilomenaQuery.Parse.String
-
+ alias PhilomenaQuery.Parse.String
alias Philomena.Images.Query
# No options
@@ -50,7 +49,10 @@ defmodule PhilomenaWeb.ImageFilterPlug do
end
defp invalid_filter_guard(user, search_string) do
- case Query.compile(user, normalize(search_string)) do
+ search_string
+ |> String.normalize()
+ |> Query.compile(user: user)
+ |> case do
{:ok, query} -> query
_error -> %{match_all: %{}}
end
diff --git a/lib/philomena_web/plugs/load_poll_plug.ex b/lib/philomena_web/plugs/load_poll_plug.ex
index 5bbfd257..6a8bec7f 100644
--- a/lib/philomena_web/plugs/load_poll_plug.ex
+++ b/lib/philomena_web/plugs/load_poll_plug.ex
@@ -2,32 +2,20 @@ defmodule PhilomenaWeb.LoadPollPlug do
alias Philomena.Polls.Poll
alias Philomena.Repo
- import Plug.Conn, only: [assign: 3]
- import Canada.Can, only: [can?: 3]
import Ecto.Query
- def init(opts),
- do: opts
-
- def call(%{assigns: %{topic: topic}} = conn, opts) do
- show_hidden = Keyword.get(opts, :show_hidden, false)
+ def init(opts), do: opts
+ def call(%{assigns: %{topic: topic}} = conn, _opts) do
Poll
|> where(topic_id: ^topic.id)
|> Repo.one()
- |> maybe_hide_poll(conn, show_hidden)
- end
+ |> case do
+ nil ->
+ PhilomenaWeb.NotFoundPlug.call(conn)
- defp maybe_hide_poll(nil, conn, _show_hidden),
- do: PhilomenaWeb.NotFoundPlug.call(conn)
-
- defp maybe_hide_poll(%{hidden_from_users: false} = poll, conn, _show_hidden),
- do: assign(conn, :poll, poll)
-
- defp maybe_hide_poll(poll, %{assigns: %{current_user: user}} = conn, show_hidden) do
- case show_hidden or can?(user, :show, poll) do
- true -> assign(conn, :poll, poll)
- false -> PhilomenaWeb.NotAuthorizedPlug.call(conn)
+ poll ->
+ Plug.Conn.assign(conn, :poll, poll)
end
end
end
diff --git a/lib/philomena_web/plugs/notification_count_plug.ex b/lib/philomena_web/plugs/notification_count_plug.ex
index d8afbef9..8f4f7913 100644
--- a/lib/philomena_web/plugs/notification_count_plug.ex
+++ b/lib/philomena_web/plugs/notification_count_plug.ex
@@ -32,7 +32,7 @@ defmodule PhilomenaWeb.NotificationCountPlug do
defp maybe_assign_notifications(conn, nil), do: conn
defp maybe_assign_notifications(conn, user) do
- notifications = Notifications.count_unread_notifications(user)
+ notifications = Notifications.total_unread_notification_count(user)
Conn.assign(conn, :notification_count, notifications)
end
diff --git a/lib/philomena_web/plugs/user_attribution_plug.ex b/lib/philomena_web/plugs/user_attribution_plug.ex
index bfbd5da8..88bcb75d 100644
--- a/lib/philomena_web/plugs/user_attribution_plug.ex
+++ b/lib/philomena_web/plugs/user_attribution_plug.ex
@@ -24,9 +24,7 @@ defmodule PhilomenaWeb.UserAttributionPlug do
attributes = [
ip: remote_ip,
fingerprint: fingerprint(conn, conn.path_info),
- referrer: referrer(conn.assigns.referrer),
- user: user,
- user_agent: user_agent(conn)
+ user: user
]
conn
@@ -47,7 +45,4 @@ defmodule PhilomenaWeb.UserAttributionPlug do
defp fingerprint(conn, _) do
conn.cookies["_ses"]
end
-
- defp referrer(nil), do: nil
- defp referrer(r), do: String.slice(r, 0, 255)
end
diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex
index 7a89f4b1..2ed82dc1 100644
--- a/lib/philomena_web/router.ex
+++ b/lib/philomena_web/router.ex
@@ -173,6 +173,7 @@ defmodule PhilomenaWeb.Router do
scope "/notifications", Notification, as: :notification do
resources "/unread", UnreadController, only: [:index]
+ resources "/categories", CategoryController, only: [:show]
end
resources "/notifications", NotificationController, only: [:index, :delete]
@@ -262,8 +263,6 @@ defmodule PhilomenaWeb.Router do
resources "/subscription", Forum.SubscriptionController,
only: [:create, :delete],
singleton: true
-
- resources "/read", Forum.ReadController, only: [:create], singleton: true
end
resources "/profiles", ProfileController, only: [] do
@@ -397,6 +396,7 @@ defmodule PhilomenaWeb.Router do
singleton: true
resources "/unlock", User.UnlockController, only: [:create], singleton: true
+ resources "/erase", User.EraseController, only: [:new, :create], singleton: true
resources "/api_key", User.ApiKeyController, only: [:delete], singleton: true
resources "/downvotes", User.DownvoteController, only: [:delete], singleton: true
resources "/votes", User.VoteController, only: [:delete], singleton: true
diff --git a/lib/philomena_web/stats_updater.ex b/lib/philomena_web/stats_updater.ex
index 8eec95c5..b91094d8 100644
--- a/lib/philomena_web/stats_updater.ex
+++ b/lib/philomena_web/stats_updater.ex
@@ -48,7 +48,7 @@ defmodule PhilomenaWeb.StatsUpdater do
|> Phoenix.HTML.Safe.to_iodata()
|> IO.iodata_to_binary()
- now = DateTime.utc_now() |> DateTime.truncate(:second)
+ now = DateTime.utc_now(:second)
static_page = %{
title: "Statistics",
diff --git a/lib/philomena_web/templates/admin/advert/_form.html.slime b/lib/philomena_web/templates/admin/advert/_form.html.slime
index 9fcd46c7..976f95d7 100644
--- a/lib/philomena_web/templates/admin/advert/_form.html.slime
+++ b/lib/philomena_web/templates/admin/advert/_form.html.slime
@@ -27,14 +27,14 @@
= error_tag f, :title
.field
- => label f, :start_time, "Start time for the advert (usually \"now\"):"
- = text_input f, :start_time, class: "input input--wide", placeholder: "Start"
- = error_tag f, :start_time
+ => label f, :start_date, "Start time for the advert (usually \"now\"):"
+ = text_input f, :start_date, class: "input input--wide", placeholder: "Start"
+ = error_tag f, :start_date
.field
- => label f, :finish_time, "Finish time for the advert (e.g. \"2 weeks from now\"):"
- = text_input f, :finish_time, class: "input input--wide", placeholder: "Finish"
- = error_tag f, :finish_time
+ => label f, :finish_date, "Finish time for the advert (e.g. \"2 weeks from now\"):"
+ = text_input f, :finish_date, class: "input input--wide", placeholder: "Finish"
+ = error_tag f, :finish_date
.field
=> label f, :notes, "Notes (Payment details, contact info, etc):"
diff --git a/lib/philomena_web/templates/admin/fingerprint_ban/_form.html.slime b/lib/philomena_web/templates/admin/fingerprint_ban/_form.html.slime
index 74a3cc2f..8091ce58 100644
--- a/lib/philomena_web/templates/admin/fingerprint_ban/_form.html.slime
+++ b/lib/philomena_web/templates/admin/fingerprint_ban/_form.html.slime
@@ -17,9 +17,9 @@
= text_input f, :note, class: "input input--wide", placeholder: "Note"
.field
- => label f, :until, "End time relative to now, in simple English (e.g. \"1 week from now\"):"
- = text_input f, :until, class: "input input--wide", placeholder: "Until", required: true
- = error_tag f, :until
+ => label f, :valid_until, "End time relative to now, in simple English (e.g. \"1 week from now\"):"
+ = text_input f, :valid_until, class: "input input--wide", placeholder: "Until", required: true
+ = error_tag f, :valid_until
br
.field
diff --git a/lib/philomena_web/templates/admin/mod_note/_table.html.slime b/lib/philomena_web/templates/admin/mod_note/_table.html.slime
index 45207800..b872ace6 100644
--- a/lib/philomena_web/templates/admin/mod_note/_table.html.slime
+++ b/lib/philomena_web/templates/admin/mod_note/_table.html.slime
@@ -7,7 +7,7 @@ table.table
td Moderator
td Actions
tbody
- = for {body, note} <- @mod_notes do
+ = for {note, body} <- @mod_notes do
tr
td
= link_to_noted_thing(note.notable)
diff --git a/lib/philomena_web/templates/admin/site_notice/_form.html.slime b/lib/philomena_web/templates/admin/site_notice/_form.html.slime
index 2e614917..8f507ef2 100644
--- a/lib/philomena_web/templates/admin/site_notice/_form.html.slime
+++ b/lib/philomena_web/templates/admin/site_notice/_form.html.slime
@@ -30,14 +30,14 @@
h3 Run Time
.field
- => label f, :start_time, "Start time for the site notice (usually \"now\"):"
- = text_input f, :start_time, class: "input input--wide", required: true
- = error_tag f, :start_time
+ => label f, :start_date, "Start time for the site notice (usually \"now\"):"
+ = text_input f, :start_date, class: "input input--wide", required: true
+ = error_tag f, :start_date
.field
- => label f, :finish_time, "Finish time for the site notice (e.g. \"2 weeks from now\"):"
- = text_input f, :finish_time, class: "input input--wide", required: true
- = error_tag f, :finish_time
+ => label f, :finish_date, "Finish time for the site notice (e.g. \"2 weeks from now\"):"
+ = text_input f, :finish_date, class: "input input--wide", required: true
+ = error_tag f, :finish_date
h3 Enable
.field
diff --git a/lib/philomena_web/templates/admin/subnet_ban/_form.html.slime b/lib/philomena_web/templates/admin/subnet_ban/_form.html.slime
index 84f17125..74ab7ad4 100644
--- a/lib/philomena_web/templates/admin/subnet_ban/_form.html.slime
+++ b/lib/philomena_web/templates/admin/subnet_ban/_form.html.slime
@@ -17,9 +17,9 @@
= text_input f, :note, class: "input input--wide", placeholder: "Note"
.field
- => label f, :until, "End time relative to now, in simple English (e.g. \"1 week from now\"):"
- = text_input f, :until, class: "input input--wide", placeholder: "Until", required: true
- = error_tag f, :until
+ => label f, :valid_until, "End time relative to now, in simple English (e.g. \"1 week from now\"):"
+ = text_input f, :valid_until, class: "input input--wide", placeholder: "Until", required: true
+ = error_tag f, :valid_until
br
.field
diff --git a/lib/philomena_web/templates/admin/user/erase/new.html.slime b/lib/philomena_web/templates/admin/user/erase/new.html.slime
new file mode 100644
index 00000000..643181e7
--- /dev/null
+++ b/lib/philomena_web/templates/admin/user/erase/new.html.slime
@@ -0,0 +1,16 @@
+h1
+ ' Deleting all changes for user
+ = @user.name
+
+.block.block--fixed.block--warning
+ p This is IRREVERSIBLE.
+ p All user details will be destroyed.
+ p Are you really sure?
+
+.field
+ => button_to "Abort", ~p"/profiles/#{@user}", class: "button"
+ => button_to "Erase user", ~p"/admin/users/#{@user}/erase", method: "post", class: "button button--state-danger", data: [confirm: "Are you really, really sure?"]
+
+p
+ ' This automatically creates user and IP bans but does not create a fingerprint ban.
+ ' Check to see if one is necessary after erasing.
diff --git a/lib/philomena_web/templates/admin/user/index.html.slime b/lib/philomena_web/templates/admin/user/index.html.slime
index 29ceb803..bbc9c520 100644
--- a/lib/philomena_web/templates/admin/user/index.html.slime
+++ b/lib/philomena_web/templates/admin/user/index.html.slime
@@ -82,7 +82,7 @@ h1 Users
/' •
= if can?(@conn, :index, Philomena.Bans.User) do
- => link to: ~p"/admin/user_bans/new?#{[username: user.name]}" do
+ => link to: ~p"/admin/user_bans/new?#{[user_id: user.id]}" do
i.fa.icon--padded.small.fa-ban
' Ban
= if can?(@conn, :edit, Philomena.ArtistLinks.ArtistLink) do
diff --git a/lib/philomena_web/templates/admin/user_ban/_form.html.slime b/lib/philomena_web/templates/admin/user_ban/_form.html.slime
index 292f07eb..812bfc61 100644
--- a/lib/philomena_web/templates/admin/user_ban/_form.html.slime
+++ b/lib/philomena_web/templates/admin/user_ban/_form.html.slime
@@ -3,9 +3,7 @@
.alert.alert-danger
p Oops, something went wrong! Please check the errors below.
- .field
- => label f, :username, "Username:"
- = text_input f, :username, class: "input", placeholder: "Username", required: true
+ = hidden_input f, :user_id
.field
=> label f, :reason, "Reason (shown to the banned user, and to staff on the user's profile page):"
@@ -17,9 +15,9 @@
= text_input f, :note, class: "input input--wide", placeholder: "Note"
.field
- => label f, :until, "End time relative to now, in simple English (e.g. \"1 week from now\"):"
- = text_input f, :until, class: "input input--wide", placeholder: "Until", required: true
- = error_tag f, :until
+ => label f, :valid_until, "End time relative to now, in simple English (e.g. \"1 week from now\"):"
+ = text_input f, :valid_until, class: "input input--wide", placeholder: "Until", required: true
+ = error_tag f, :valid_until
br
.field
diff --git a/lib/philomena_web/templates/admin/user_ban/edit.html.slime b/lib/philomena_web/templates/admin/user_ban/edit.html.slime
index 604f0ed6..9d3349a9 100644
--- a/lib/philomena_web/templates/admin/user_ban/edit.html.slime
+++ b/lib/philomena_web/templates/admin/user_ban/edit.html.slime
@@ -1,4 +1,6 @@
-h1 Editing ban
+h1
+ ' Editing user ban for user
+ = @user.user.name
= render PhilomenaWeb.Admin.UserBanView, "_form.html", changeset: @changeset, action: ~p"/admin/user_bans/#{@user}", conn: @conn
diff --git a/lib/philomena_web/templates/admin/user_ban/index.html.slime b/lib/philomena_web/templates/admin/user_ban/index.html.slime
index 61087b58..24abf0b8 100644
--- a/lib/philomena_web/templates/admin/user_ban/index.html.slime
+++ b/lib/philomena_web/templates/admin/user_ban/index.html.slime
@@ -10,10 +10,6 @@ h1 User Bans
.block
.block__header
- a href=~p"/admin/user_bans/new"
- i.fa.fa-plus>
- ' New user ban
-
= pagination
.block__content
diff --git a/lib/philomena_web/templates/admin/user_ban/new.html.slime b/lib/philomena_web/templates/admin/user_ban/new.html.slime
index cdd82ff8..55293ac5 100644
--- a/lib/philomena_web/templates/admin/user_ban/new.html.slime
+++ b/lib/philomena_web/templates/admin/user_ban/new.html.slime
@@ -1,4 +1,7 @@
-h1 New User Ban
+h1
+ ' New User Ban for user
+ = @target_user.name
+
= render PhilomenaWeb.Admin.UserBanView, "_form.html", changeset: @changeset, action: ~p"/admin/user_bans", conn: @conn
br
diff --git a/lib/philomena_web/templates/conversation/index.html.slime b/lib/philomena_web/templates/conversation/index.html.slime
index 4528329b..a32d4082 100644
--- a/lib/philomena_web/templates/conversation/index.html.slime
+++ b/lib/philomena_web/templates/conversation/index.html.slime
@@ -20,14 +20,14 @@ h1 My Conversations
th.table--communication-list__stats With
th.table--communication-list__options Options
tbody
- = for {c, count} <- @conversations do
+ = for c <- @conversations do
tr class=conversation_class(@conn.assigns.current_user, c)
td.table--communication-list__name
=> link c.title, to: ~p"/conversations/#{c}"
.small-text.hidden--mobile
- => count
- = pluralize("message", "messages", count)
+ => c.message_count
+ = pluralize("message", "messages", c.message_count)
' ; started
= pretty_time(c.created_at)
' , last message
@@ -36,7 +36,7 @@ h1 My Conversations
td.table--communication-list__stats
= render PhilomenaWeb.UserAttributionView, "_user.html", object: %{user: other_party(@current_user, c)}, conn: @conn
td.table--communication-list__options
- => link "Last message", to: last_message_path(c, count)
+ => link "Last message", to: last_message_path(c, c.message_count)
' •
=> link "Hide", to: ~p"/conversations/#{c}/hide", data: [method: "post"], data: [confirm: "Are you really, really sure?"]
diff --git a/lib/philomena_web/templates/image/new.html.slime b/lib/philomena_web/templates/image/new.html.slime
index e4a77780..b40ed145 100644
--- a/lib/philomena_web/templates/image/new.html.slime
+++ b/lib/philomena_web/templates/image/new.html.slime
@@ -88,4 +88,4 @@
= render PhilomenaWeb.CaptchaView, "_captcha.html", name: "image", conn: @conn
.actions
- = submit "Upload", class: "button input--separate-top", autocomplete: "off", data: [disable_with: "Please wait..."]
+ = submit "Upload", class: "button input--separate-top", autocomplete: "off"
diff --git a/lib/philomena_web/templates/notification/_channel.html.slime b/lib/philomena_web/templates/notification/_channel.html.slime
index 1fc57157..c22299db 100644
--- a/lib/philomena_web/templates/notification/_channel.html.slime
+++ b/lib/philomena_web/templates/notification/_channel.html.slime
@@ -1,14 +1,14 @@
.flex.flex--centered.flex__grow
div
strong>
- = link @notification.actor.title, to: ~p"/channels/#{@notification.actor}"
- =<> @notification.action
+ = link @notification.channel.title, to: ~p"/channels/#{@notification.channel}"
+ ' went live
=> pretty_time @notification.updated_at
.flex.flex--centered.flex--no-wrap
- a.button.button--separate-right title="Delete" href=~p"/channels/#{@notification.actor}/read" data-method="post" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}"
+ a.button.button--separate-right title="Delete" href=~p"/channels/#{@notification.channel}/read" data-method="post" data-remote="true" data-click-hideparent=".notification"
i.fa.fa-trash
- a.button title="Unsubscribe" href=~p"/channels/#{@notification.actor}/subscription" data-method="delete" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}"
- i.fa.fa-bell-slash
\ No newline at end of file
+ a.button title="Unsubscribe" href=~p"/channels/#{@notification.channel}/subscription" data-method="delete" data-remote="true" data-click-hideparent=".notification"
+ i.fa.fa-bell-slash
diff --git a/lib/philomena_web/templates/notification/_comment.html.slime b/lib/philomena_web/templates/notification/_comment.html.slime
new file mode 100644
index 00000000..076ccb34
--- /dev/null
+++ b/lib/philomena_web/templates/notification/_comment.html.slime
@@ -0,0 +1,22 @@
+- comment = @notification.comment
+- image = @notification.image
+
+.flex.flex--centered.flex__fixed.thumb-tiny-container.spacing-right
+ = render PhilomenaWeb.ImageView, "_image_container.html", image: image, size: :thumb_tiny, conn: @conn
+
+.flex.flex--centered.flex__grow
+ div
+ => render PhilomenaWeb.UserAttributionView, "_anon_user.html", object: comment, conn: @conn
+ ' commented on
+
+ strong>
+ = link "##{image.id}", to: ~p"/images/#{image}" <> "#comments"
+
+ => pretty_time @notification.updated_at
+
+.flex.flex--centered.flex--no-wrap
+ a.button.button--separate-right title="Delete" href=~p"/images/#{image}/read" data-method="post" data-remote="true" data-click-hideparent=".notification"
+ i.fa.fa-trash
+
+ a.button title="Unsubscribe" href=~p"/images/#{image}/subscription" data-method="delete" data-remote="true" data-click-hideparent=".notification"
+ i.fa.fa-bell-slash
diff --git a/lib/philomena_web/templates/notification/_forum.html.slime b/lib/philomena_web/templates/notification/_forum.html.slime
deleted file mode 100644
index f7edb198..00000000
--- a/lib/philomena_web/templates/notification/_forum.html.slime
+++ /dev/null
@@ -1,25 +0,0 @@
-- forum = @notification.actor
-- topic = @notification.actor_child
-
-.flex.flex--centered.flex__grow
- div
- => render PhilomenaWeb.UserAttributionView, "_anon_user.html", object: topic, conn: @conn
- => @notification.action
-
- ' titled
-
- strong>
- = link topic.title, to: ~p"/forums/#{forum}/topics/#{topic}"
-
- ' in
-
- => link forum.name, to: ~p"/forums/#{forum}"
-
- => pretty_time @notification.updated_at
-
-.flex.flex--centered.flex--no-wrap
- a.button.button--separate-right title="Delete" href=~p"/forums/#{forum}/read" data-method="post" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}"
- i.fa.fa-trash
-
- a.button title="Unsubscribe" href=~p"/forums/#{forum}/subscription" data-method="delete" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}"
- i.fa.fa-bell-slash
\ No newline at end of file
diff --git a/lib/philomena_web/templates/notification/_gallery.html.slime b/lib/philomena_web/templates/notification/_gallery.html.slime
index 09e3eccc..8ef024d0 100644
--- a/lib/philomena_web/templates/notification/_gallery.html.slime
+++ b/lib/philomena_web/templates/notification/_gallery.html.slime
@@ -1,16 +1,18 @@
+- gallery = @notification.gallery
+
.flex.flex--centered.flex__grow
div
- => render PhilomenaWeb.UserAttributionView, "_user.html", object: %{user: @notification.actor.creator}, conn: @conn
- => @notification.action
+ => render PhilomenaWeb.UserAttributionView, "_user.html", object: %{user: gallery.creator}, conn: @conn
+ ' added images to
strong>
- = link @notification.actor.title, to: ~p"/galleries/#{@notification.actor}"
+ = link gallery.title, to: ~p"/galleries/#{gallery}"
=> pretty_time @notification.updated_at
.flex.flex--centered.flex--no-wrap
- a.button.button--separate-right title="Delete" href=~p"/galleries/#{@notification.actor}/read" data-method="post" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}"
+ a.button.button--separate-right title="Delete" href=~p"/galleries/#{gallery}/read" data-method="post" data-remote="true" data-click-hideparent=".notification"
i.fa.fa-trash
- a.button title="Unsubscribe" href=~p"/galleries/#{@notification.actor}/subscription" data-method="delete" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}"
- i.fa.fa-bell-slash
\ No newline at end of file
+ a.button title="Unsubscribe" href=~p"/galleries/#{gallery}/subscription" data-method="delete" data-remote="true" data-click-hideparent=".notification"
+ i.fa.fa-bell-slash
diff --git a/lib/philomena_web/templates/notification/_image.html.slime b/lib/philomena_web/templates/notification/_image.html.slime
index 89814c39..dcfad4eb 100644
--- a/lib/philomena_web/templates/notification/_image.html.slime
+++ b/lib/philomena_web/templates/notification/_image.html.slime
@@ -1,19 +1,24 @@
+- target = @notification.target
+- source = @notification.source
+
+.flex.flex--centered.flex__fixed.thumb-tiny-container.spacing-right
+ = render PhilomenaWeb.ImageView, "_image_container.html", image: target, size: :thumb_tiny, conn: @conn
+
.flex.flex--centered.flex__grow
div
- = if @notification.actor_child do
- => render PhilomenaWeb.UserAttributionView, "_anon_user.html", object: @notification.actor_child, conn: @conn
- - else
- ' Someone
- => @notification.action
+ ' Someone
+ | merged #
+ => source.id
+ ' into
strong>
- = link "##{@notification.actor_id}", to: ~p"/images/#{@notification.actor}" <> "#comments"
+ = link "##{target.id}", to: ~p"/images/#{target}" <> "#comments"
=> pretty_time @notification.updated_at
.flex.flex--centered.flex--no-wrap
- a.button.button--separate-right title="Delete" href=~p"/images/#{@notification.actor}/read" data-method="post" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}"
+ a.button.button--separate-right title="Delete" href=~p"/images/#{target}/read" data-method="post" data-remote="true" data-click-hideparent=".notification"
i.fa.fa-trash
- a.button title="Unsubscribe" href=~p"/images/#{@notification.actor}/subscription" data-method="delete" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}"
- i.fa.fa-bell-slash
\ No newline at end of file
+ a.button title="Unsubscribe" href=~p"/images/#{target}/subscription" data-method="delete" data-remote="true" data-click-hideparent=".notification"
+ i.fa.fa-bell-slash
diff --git a/lib/philomena_web/templates/notification/_notification.html.slime b/lib/philomena_web/templates/notification/_notification.html.slime
deleted file mode 100644
index 7a592713..00000000
--- a/lib/philomena_web/templates/notification/_notification.html.slime
+++ /dev/null
@@ -1,7 +0,0 @@
-= if @notification.actor do
- .block.block--fixed.flex id="notification-#{@notification.id}"
- = if @notification.actor_type == "Image" and @notification.actor do
- .flex.flex--centered.flex__fixed.media-tiny-container.spacing--right
- = render PhilomenaWeb.ImageView, "_image_container.html", image: @notification.actor, size: :thumb_tiny, conn: @conn
-
- => render PhilomenaWeb.NotificationView, notification_template_path(@notification.actor_type), notification: @notification, conn: @conn
diff --git a/lib/philomena_web/templates/notification/_post.html.slime b/lib/philomena_web/templates/notification/_post.html.slime
new file mode 100644
index 00000000..f0dc0b66
--- /dev/null
+++ b/lib/philomena_web/templates/notification/_post.html.slime
@@ -0,0 +1,19 @@
+- topic = @notification.topic
+- post = @notification.post
+
+.flex.flex--centered.flex__grow
+ div
+ => render PhilomenaWeb.UserAttributionView, "_anon_user.html", object: post, conn: @conn
+ ' posted a new reply in
+
+ strong>
+ = link topic.title, to: ~p"/forums/#{topic.forum}/topics/#{topic}?#{[post_id: post.id]}" <> "#post_#{post.id}"
+
+ => pretty_time @notification.updated_at
+
+.flex.flex--centered.flex--no-wrap
+ a.button.button--separate-right title="Delete" href=~p"/forums/#{topic.forum}/topics/#{topic}/read" data-method="post" data-remote="true" data-click-hideparent=".notification"
+ i.fa.fa-trash
+
+ a.button title="Unsubscribe" href=~p"/forums/#{topic.forum}/topics/#{topic}/subscription" data-method="delete" data-remote="true" data-click-hideparent=".notification"
+ i.fa.fa-bell-slash
diff --git a/lib/philomena_web/templates/notification/_topic.html.slime b/lib/philomena_web/templates/notification/_topic.html.slime
index 5ecefcfd..37f6786d 100644
--- a/lib/philomena_web/templates/notification/_topic.html.slime
+++ b/lib/philomena_web/templates/notification/_topic.html.slime
@@ -1,19 +1,20 @@
-- topic = @notification.actor
-- post = @notification.actor_child
+- topic = @notification.topic
+- forum = topic.forum
.flex.flex--centered.flex__grow
div
- => render PhilomenaWeb.UserAttributionView, "_anon_user.html", object: post, conn: @conn
- => @notification.action
+ => render PhilomenaWeb.UserAttributionView, "_anon_user.html", object: topic, conn: @conn
+ ' posted a new topic titled
strong>
- = link topic.title, to: ~p"/forums/#{topic.forum}/topics/#{topic}?#{[post_id: post.id]}" <> "#post_#{post.id}"
+ = link topic.title, to: ~p"/forums/#{forum}/topics/#{topic}"
+
+ ' in
+
+ => link forum.name, to: ~p"/forums/#{forum}"
=> pretty_time @notification.updated_at
.flex.flex--centered.flex--no-wrap
- a.button.button--separate-right title="Delete" href=~p"/forums/#{topic.forum}/topics/#{topic}/read" data-method="post" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}"
+ a.button.button--separate-right title="Delete" href=~p"/forums/#{forum}/topics/#{topic}/read" data-method="post" data-remote="true" data-click-hideparent=".notification"
i.fa.fa-trash
-
- a.button title="Unsubscribe" href=~p"/forums/#{topic.forum}/topics/#{topic}/subscription" data-method="delete" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}"
- i.fa.fa-bell-slash
\ No newline at end of file
diff --git a/lib/philomena_web/templates/notification/category/show.html.slime b/lib/philomena_web/templates/notification/category/show.html.slime
new file mode 100644
index 00000000..a8a39ab5
--- /dev/null
+++ b/lib/philomena_web/templates/notification/category/show.html.slime
@@ -0,0 +1,29 @@
+h1 Notification Area
+.walloftext
+ = cond do
+ - Enum.any?(@notifications) ->
+ - route = fn p -> ~p"/notifications/categories/#{@category}?#{p}" end
+ - pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @notifications, route: route, conn: @conn
+
+ .block.notification-type-block
+ .block__header
+ span.block__header__title = name_of_category(@category)
+ .block__header.block__header__sub
+ = pagination
+
+ div
+ = for notification <- @notifications do
+ .block.block--fixed.flex.notification
+ = render PhilomenaWeb.NotificationView, notification_template_path(@category), notification: notification, conn: @conn
+
+ .block__header.block__header--light
+ = pagination
+
+ - true ->
+ p You currently have no notifications of this category.
+ p
+ ' To get notifications on new comments and forum posts, click the
+ ' 'Subscribe' button in the bar at the top of an image or forum topic.
+
+ a.button href=~p"/notifications"
+ ' View all notifications
diff --git a/lib/philomena_web/templates/notification/index.html.slime b/lib/philomena_web/templates/notification/index.html.slime
index 4503680d..ab6b4a28 100644
--- a/lib/philomena_web/templates/notification/index.html.slime
+++ b/lib/philomena_web/templates/notification/index.html.slime
@@ -1,18 +1,22 @@
-- route = fn p -> ~p"/notifications?#{p}" end
-
h1 Notification Area
.walloftext
- = if @notifications.total_pages > 1 do
- .block__header
- = render PhilomenaWeb.PaginationView, "_pagination.html", page: @notifications, route: route, conn: @conn
+ = for {category, notifications} <- @notifications, Enum.any?(notifications) do
+ .block.notification-type-block
+ .block__header
+ span.block__header__title = name_of_category(category)
- = cond do
- - Enum.any?(@notifications) ->
- = for notification <- @notifications do
- = render PhilomenaWeb.NotificationView, "_notification.html", notification: notification, conn: @conn
+ div
+ = for notification <- notifications do
+ .block.block--fixed.flex.notification
+ = render PhilomenaWeb.NotificationView, notification_template_path(category), notification: notification, conn: @conn
- - true ->
- p
- ' To get notifications on new comments and forum posts, click the
- ' 'Subscribe' button in the bar at the top of an image or forum topic.
- ' You'll get notifications here for any new posts or comments.
+ .block__header.block__header--light
+ a href=~p"/notifications/categories/#{category}"
+ | View category (
+ = notifications.total_entries
+ | )
+
+ p
+ ' To get notifications on new comments and forum posts, click the
+ ' 'Subscribe' button in the bar at the top of an image or forum topic.
+ ' You'll get notifications here for any new posts or comments.
diff --git a/lib/philomena_web/templates/profile/_admin_block.html.slime b/lib/philomena_web/templates/profile/_admin_block.html.slime
index 88eb5b82..28a583de 100644
--- a/lib/philomena_web/templates/profile/_admin_block.html.slime
+++ b/lib/philomena_web/templates/profile/_admin_block.html.slime
@@ -135,10 +135,11 @@
i.fa.icon--padded.small.fa-link
span.admin__button Add Artist Link
- li
- = link to: ~p"/admin/users/#{@user}/force_filter/new" do
- i.fas.faw-fw.fa-filter
- span.admin__button Force Filter
+ = if can?(@conn, :create, Philomena.Bans.User) do
+ li
+ = link to: ~p"/admin/user_bans/new?#{[user_id: @user.id]}" do
+ i.fa.fa-fw.fa-ban
+ span.admin__button Ban this sucker
ul.profile-admin__options__column
= if can?(@conn, :index, Philomena.Users.User) do
@@ -169,6 +170,12 @@
i.fa.icon--padded.small.fa-arrow-down
span.admin__button Remove All Downvotes
+ = if @user.role == "user" do
+ li
+ = link to: ~p"/admin/users/#{@user}/erase/new", data: [confirm: "Are you really, really sure?"] do
+ i.fa.fa-fw.fa-warning
+ span.admin__button Erase for spam
+
= if @user.role == "user" and can?(@conn, :revert, Philomena.TagChanges.TagChange) do
li
= link to: ~p"/tag_changes/full_revert?#{[user_id: @user.id]}", data: [confirm: "Are you really, really sure?", method: "create"] do
diff --git a/lib/philomena_web/templates/profile/show.html.slime b/lib/philomena_web/templates/profile/show.html.slime
index 1eed7c65..7fedd8c6 100644
--- a/lib/philomena_web/templates/profile/show.html.slime
+++ b/lib/philomena_web/templates/profile/show.html.slime
@@ -143,7 +143,7 @@
' Edit
= if Enum.count(@user.awards) == 0 do
.block__content
- p
+ p
span: i.fa.icon--padded--right.fa-info-circle
| No badge awards received
.block
@@ -177,12 +177,12 @@
th Note
th Created
tbody
- = for {body, mod_note} <- @mod_notes do
+ = for {mod_note, body} <- @mod_notes do
tr
td = body
td = pretty_time(mod_note.created_at)
- else
- p
+ p
span: i.fa.icon--padded--right.fa-info-circle
| No mod notes
= if can_index_user?(@conn) do
@@ -200,7 +200,7 @@
= if String.trim(contents) != "" do
= @scratchpad
- else
- p
+ p
span: i.fa.icon--padded--right.fa-info-circle
| No information present
diff --git a/lib/philomena_web/templates/report/new.html.slime b/lib/philomena_web/templates/report/new.html.slime
index f7839783..bdc820cd 100644
--- a/lib/philomena_web/templates/report/new.html.slime
+++ b/lib/philomena_web/templates/report/new.html.slime
@@ -47,6 +47,7 @@ p
.block
= render PhilomenaWeb.MarkdownView, "_input.html", conn: @conn, f: f, placeholder: "Provide anything else we should know here.", name: :reason, required: false
+ = hidden_input f, :user_agent, value: get_user_agent(@conn)
= render PhilomenaWeb.CaptchaView, "_captcha.html", name: "report", conn: @conn
= submit "Send Report", class: "button"
diff --git a/lib/philomena_web/templates/search/reverse/index.html.slime b/lib/philomena_web/templates/search/reverse/index.html.slime
index 6ae7b2d7..3d9f83ba 100644
--- a/lib/philomena_web/templates/search/reverse/index.html.slime
+++ b/lib/philomena_web/templates/search/reverse/index.html.slime
@@ -1,18 +1,23 @@
h1 Reverse Search
-= form_for :image, ~p"/search/reverse", [multipart: true], fn f ->
- p
- ' Basic image similarity search. Finds uploaded images similar to the one
- ' provided based on simple intensities and uses the median frame of
- ' animations; very low contrast images (such as sketches) will produce
- ' poor results and, regardless of contrast, results may include seemingly
- ' random images that look very different.
+= form_for @changeset, ~p"/search/reverse", [multipart: true, as: :image], fn f ->
+ .walloftext
+ p
+ ' Basic image similarity search. Finds uploaded images similar to the one
+ ' provided based on simple intensities and uses the median frame of
+ ' animations; very low contrast images (such as sketches) will produce
+ ' poor results and, regardless of contrast, results may include seemingly
+ ' random images that look very different.
.image-other
#js-image-upload-previews
p Upload a file from your computer, or provide a link to the page containing the image and click Fetch.
.field
= file_input f, :image, class: "input js-scraper"
+ = error_tag f, :uploaded_image
+ = error_tag f, :image_width
+ = error_tag f, :image_height
+ = error_tag f, :image_mime_type
.field.field--inline
= url_input f, :url, name: "url", class: "input input--wide js-scraper", placeholder: "Link a deviantART page, a Tumblr post, or the image directly"
@@ -26,16 +31,23 @@ h1 Reverse Search
.field
= label f, :distance, "Match distance (suggested values: between 0.2 and 0.5)"
br
- = number_input f, :distance, value: 0.25, min: 0, max: 1, step: 0.01, class: "input"
+ = number_input f, :distance, min: 0, max: 1, step: 0.01, class: "input"
+ = error_tag f, :distance
+
+ = error_tag f, :limit
.field
= submit "Reverse Search", class: "button"
= cond do
- is_nil(@images) ->
+ / Don't render anything.
- Enum.any?(@images) ->
- h2 Results
+ .block#imagelist-container
+ section.block__header.page__header.flex
+ span.block__header__title.page__title.hide-mobile
+ ' Search by uploaded image
table
tr
@@ -54,21 +66,20 @@ h1 Reverse Search
- else
' Unknown source
- th
- = render PhilomenaWeb.ImageView, "_image_container.html", image: match, size: :thumb, conn: @conn
+ .block__content
+ = for image <- @images do
+ = render PhilomenaWeb.ImageView, "_image_box.html", image: image, link: ~p"/images/#{image}", size: :thumb, conn: @conn
- th
- h3
- = match.image_width
- | x
- => match.image_height
- ' -
- => round(match.image_size / 1024)
- ' KiB
+ .block__header.block__header--light.page__header.flex
+ span.block__header__title.page__info
+ = render PhilomenaWeb.PaginationView, "_pagination_info.html", page: @images
- = render PhilomenaWeb.TagView, "_tag_list.html", tags: Tag.display_order(match.tags), conn: @conn
+ .flex__right.page__options
+ a href="/settings/edit" title="Display Settings"
+ i.fa.fa-cog
+ span.hide-mobile.hide-limited-desktop<>
+ ' Display Settings
- true ->
- h2 Results
p
- ' We couldn't find any images matching this in our image database.
+ ' No images found!
diff --git a/lib/philomena_web/templates/tag/_tag_editor.html.slime b/lib/philomena_web/templates/tag/_tag_editor.html.slime
index 10735472..e1f7276a 100644
--- a/lib/philomena_web/templates/tag/_tag_editor.html.slime
+++ b/lib/philomena_web/templates/tag/_tag_editor.html.slime
@@ -11,7 +11,6 @@ elixir:
.js-taginput.input.input--wide.tagsinput.hidden class="js-taginput-fancy" data-click-focus=".js-taginput-input.js-taginput-#{@name}"
input.input class="js-taginput-input js-taginput-#{@name}" id="taginput-fancy-#{@name}" type="text" placeholder="add a tag" autocomplete="off" autocapitalize="none" data-ac="true" data-ac-min-length="3" data-ac-source="/autocomplete/tags?term="
button.button.button--primary.button--bold class="js-taginput-show" data-click-show=".js-taginput-fancy,.js-taginput-hide" data-click-hide=".js-taginput-plain,.js-taginput-show" data-click-focus=".js-taginput-input.js-taginput-#{@name}"
- = hidden_input :fuck_ie, :fuck_ie, value: "fuck_ie"
' Fancy Editor
button.hidden.button.button--primary.button--bold class="js-taginput-hide" data-click-show=".js-taginput-plain,.js-taginput-show" data-click-hide=".js-taginput-fancy,.js-taginput-hide" data-click-focus=".js-taginput-plain.js-taginput-#{@name}"
' Plain Editor
diff --git a/lib/philomena_web/templates/topic/poll/_display.html.slime b/lib/philomena_web/templates/topic/poll/_display.html.slime
index 37e7711c..079f1b46 100644
--- a/lib/philomena_web/templates/topic/poll/_display.html.slime
+++ b/lib/philomena_web/templates/topic/poll/_display.html.slime
@@ -6,26 +6,12 @@
= link "Administrate", to: "#", data: [click_tab: "administration"]
.block__tab data-tab="voting"
- = cond do
- - @poll.hidden_from_users ->
- .walloftext
- .block.block--fixed.block--warning
- h1 This poll has been deleted
- p
- ' Reason:
- strong
- = @poll.deletion_reason || "Unknown (likely deleted in error). Please contact a moderator."
-
- - @poll_active and not @voted and not is_nil(@conn.assigns.current_user) ->
- .poll
- .poll-area
- = render PhilomenaWeb.Topic.PollView, "_vote_form.html", assigns
-
- - true ->
- .poll
- .poll-area
- = render PhilomenaWeb.Topic.PollView, "_results.html", assigns
-
+ .poll
+ .poll-area
+ = if @poll_active and not @voted and not is_nil(@conn.assigns.current_user) do
+ = render PhilomenaWeb.Topic.PollView, "_vote_form.html", assigns
+ - else
+ = render PhilomenaWeb.Topic.PollView, "_results.html", assigns
= if can?(@conn, :hide, @topic) do
.block__tab.hidden data-tab="voters"
diff --git a/lib/philomena_web/templates/topic/poll/_form.html.slime b/lib/philomena_web/templates/topic/poll/_form.html.slime
index f5d960b0..682d3c43 100644
--- a/lib/philomena_web/templates/topic/poll/_form.html.slime
+++ b/lib/philomena_web/templates/topic/poll/_form.html.slime
@@ -12,8 +12,7 @@ p.fieldlabel
' End date
.field.field--block
- = text_input @f, :until, class: "input input--wide", placeholder: "2 weeks from now", maxlength: 255
- = error_tag @f, :until
+ = text_input @f, :active_until, class: "input input--wide", placeholder: "2 weeks from now", maxlength: 255
= error_tag @f, :active_until
p.fieldlabel
diff --git a/lib/philomena_web/user_auth.ex b/lib/philomena_web/user_auth.ex
index c7bf2431..c463cf35 100644
--- a/lib/philomena_web/user_auth.ex
+++ b/lib/philomena_web/user_auth.ex
@@ -210,7 +210,7 @@ defmodule PhilomenaWeb.UserAuth do
defp signed_in_path(_conn), do: "/"
defp update_usages(conn, user) do
- now = DateTime.utc_now() |> DateTime.truncate(:second)
+ now = DateTime.utc_now(:second)
UserIpUpdater.cast(user.id, conn.remote_ip, now)
UserFingerprintUpdater.cast(user.id, conn.assigns.fingerprint, now)
diff --git a/lib/philomena_web/views/admin/user/erase_view.ex b/lib/philomena_web/views/admin/user/erase_view.ex
new file mode 100644
index 00000000..2c497038
--- /dev/null
+++ b/lib/philomena_web/views/admin/user/erase_view.ex
@@ -0,0 +1,3 @@
+defmodule PhilomenaWeb.Admin.User.EraseView do
+ use PhilomenaWeb, :view
+end
diff --git a/lib/philomena_web/views/api/json/image_view.ex b/lib/philomena_web/views/api/json/image_view.ex
index d6c8b951..f72a676e 100644
--- a/lib/philomena_web/views/api/json/image_view.ex
+++ b/lib/philomena_web/views/api/json/image_view.ex
@@ -60,6 +60,7 @@ defmodule PhilomenaWeb.Api.Json.ImageView do
height: image.image_height,
mime_type: image.image_mime_type,
size: image.image_size,
+ orig_size: image.image_orig_size,
duration: image.image_duration,
animated: image.image_is_animated,
format: image.image_format,
diff --git a/lib/philomena_web/views/channel_view.ex b/lib/philomena_web/views/channel_view.ex
index acb4880a..c8f1927c 100644
--- a/lib/philomena_web/views/channel_view.ex
+++ b/lib/philomena_web/views/channel_view.ex
@@ -1,27 +1,7 @@
defmodule PhilomenaWeb.ChannelView do
use PhilomenaWeb, :view
- def channel_image(%{type: "LivestreamChannel", short_name: short_name}) do
- now = DateTime.utc_now() |> DateTime.to_unix(:microsecond)
-
- PhilomenaProxy.Camo.image_url(
- "https://thumbnail.api.livestream.com/thumbnail?name=#{short_name}&rand=#{now}"
- )
+ def channel_image(%{thumbnail_url: thumbnail_url}) do
+ PhilomenaProxy.Camo.image_url(thumbnail_url || "https://picarto.tv/images/missingthumb.jpg")
end
-
- def channel_image(%{type: "PicartoChannel", thumbnail_url: thumbnail_url}),
- do:
- PhilomenaProxy.Camo.image_url(thumbnail_url || "https://picarto.tv/images/missingthumb.jpg")
-
- def channel_image(%{type: "PiczelChannel", remote_stream_id: remote_stream_id}),
- do:
- PhilomenaProxy.Camo.image_url(
- "https://piczel.tv/api/thumbnail/stream_#{remote_stream_id}.jpg"
- )
-
- def channel_image(%{type: "TwitchChannel", short_name: short_name}),
- do:
- PhilomenaProxy.Camo.image_url(
- "https://static-cdn.jtvnw.net/previews-ttv/live_user_#{String.downcase(short_name)}-320x180.jpg"
- )
end
diff --git a/lib/philomena_web/views/notification/category_view.ex b/lib/philomena_web/views/notification/category_view.ex
new file mode 100644
index 00000000..8c6717a6
--- /dev/null
+++ b/lib/philomena_web/views/notification/category_view.ex
@@ -0,0 +1,6 @@
+defmodule PhilomenaWeb.Notification.CategoryView do
+ use PhilomenaWeb, :view
+
+ defdelegate name_of_category(category), to: PhilomenaWeb.NotificationView
+ defdelegate notification_template_path(category), to: PhilomenaWeb.NotificationView
+end
diff --git a/lib/philomena_web/views/notification_view.ex b/lib/philomena_web/views/notification_view.ex
index 52d05201..5d30e4d9 100644
--- a/lib/philomena_web/views/notification_view.ex
+++ b/lib/philomena_web/views/notification_view.ex
@@ -2,15 +2,37 @@ defmodule PhilomenaWeb.NotificationView do
use PhilomenaWeb, :view
@template_paths %{
- "Channel" => "_channel.html",
- "Forum" => "_forum.html",
- "Gallery" => "_gallery.html",
- "Image" => "_image.html",
- "LivestreamChannel" => "_channel.html",
- "Topic" => "_topic.html"
+ "channel_live" => "_channel.html",
+ "forum_post" => "_post.html",
+ "forum_topic" => "_topic.html",
+ "gallery_image" => "_gallery.html",
+ "image_comment" => "_comment.html",
+ "image_merge" => "_image.html"
}
- def notification_template_path(actor_type) do
- @template_paths[actor_type]
+ def notification_template_path(category) do
+ @template_paths[to_string(category)]
+ end
+
+ def name_of_category(category) do
+ case category do
+ :channel_live ->
+ "Live channels"
+
+ :forum_post ->
+ "New replies in topics"
+
+ :forum_topic ->
+ "New topics"
+
+ :gallery_image ->
+ "Updated galleries"
+
+ :image_comment ->
+ "New replies on images"
+
+ :image_merge ->
+ "Image merges"
+ end
end
end
diff --git a/lib/philomena_web/views/report_view.ex b/lib/philomena_web/views/report_view.ex
index f65fcf09..56f0bc9f 100644
--- a/lib/philomena_web/views/report_view.ex
+++ b/lib/philomena_web/views/report_view.ex
@@ -79,4 +79,11 @@ defmodule PhilomenaWeb.ReportView do
def link_to_reported_thing(_reportable) do
"Reported item permanently destroyed."
end
+
+ def get_user_agent(conn) do
+ case Plug.Conn.get_req_header(conn, "user-agent") do
+ [ua] -> ua
+ _ -> ""
+ end
+ end
end
diff --git a/lib/philomena_web/views/search/reverse_view.ex b/lib/philomena_web/views/search/reverse_view.ex
index 498deefa..7cb4704f 100644
--- a/lib/philomena_web/views/search/reverse_view.ex
+++ b/lib/philomena_web/views/search/reverse_view.ex
@@ -1,5 +1,3 @@
defmodule PhilomenaWeb.Search.ReverseView do
use PhilomenaWeb, :view
-
- alias Philomena.Tags.Tag
end
diff --git a/lib/philomena_web/views/topic/poll_view.ex b/lib/philomena_web/views/topic/poll_view.ex
index b8c88c87..e801b43f 100644
--- a/lib/philomena_web/views/topic/poll_view.ex
+++ b/lib/philomena_web/views/topic/poll_view.ex
@@ -13,7 +13,7 @@ defmodule PhilomenaWeb.Topic.PollView do
end
def active?(poll) do
- not poll.hidden_from_users and DateTime.diff(poll.active_until, DateTime.utc_now()) > 0
+ DateTime.diff(poll.active_until, DateTime.utc_now()) > 0
end
def require_answer?(%{vote_method: vote_method}), do: vote_method == "single"
diff --git a/mix.exs b/mix.exs
index 05f26cc9..4032d859 100644
--- a/mix.exs
+++ b/mix.exs
@@ -55,7 +55,8 @@ defmodule Philomena.MixProject do
{:pot, "~> 1.0"},
{:secure_compare, "~> 0.1"},
{:nimble_parsec, "~> 1.2"},
- {:scrivener_ecto, "~> 2.7"},
+ {:scrivener_ecto,
+ github: "krns/scrivener_ecto", ref: "eaad1ddd86a9c8ffa422479417221265a0673777"},
{:pbkdf2, ">= 0.0.0",
github: "basho/erlang-pbkdf2", ref: "7e9bd5fcd3cc3062159e4c9214bb628aa6feb5ca"},
{:qrcode, "~> 0.1"},
diff --git a/mix.lock b/mix.lock
index 4d006ee9..7f179ae0 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,89 +1,81 @@
%{
- "bandit": {:hex, :bandit, "1.5.5", "df28f1c41f745401fe9e85a6882033f5f3442ab6d30c8a2948554062a4ab56e0", [:mix], [{:hpax, "~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "f21579a29ea4bc08440343b2b5f16f7cddf2fea5725d31b72cf973ec729079e1"},
+ "bandit": {:hex, :bandit, "1.5.7", "6856b1e1df4f2b0cb3df1377eab7891bec2da6a7fd69dc78594ad3e152363a50", [:mix], [{:hpax, "~> 1.0.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "f2dd92ae87d2cbea2fa9aa1652db157b6cba6c405cb44d4f6dd87abba41371cd"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.1.0", "0b110a9a6c619b19a7f73fa3004aa11d6e719a67e672d1633dc36b6b2290a0f7", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2ad2acb5a8bc049e8d5aa267802631912bb80d5f4110a178ae7999e69dca1bf7"},
"briefly": {:hex, :briefly, "0.5.1", "ee10d48da7f79ed2aebdc3e536d5f9a0c3e36ff76c0ad0d4254653a152b13a8a", [:mix], [], "hexpm", "bd684aa92ad8b7b4e0d92c31200993c4bc1469fc68cd6d5f15144041bd15cb57"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"canada": {:hex, :canada, "1.0.2", "040e4c47609b0a67d5773ac1fbe5e99f840cef173d69b739beda7c98453e0770", [:mix], [], "hexpm", "4269f74153fe89583fe50bd4d5de57bfe01f31258a6b676d296f3681f1483c68"},
"canary": {:git, "https://github.com/marcinkoziej/canary.git", "704debde7a2c0600f78c687807884bf37c45bd79", [ref: "704debde7a2c0600f78c687807884bf37c45bd79"]},
- "castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"},
- "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
+ "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"comeonin": {:hex, :comeonin, "5.4.0", "246a56ca3f41d404380fc6465650ddaa532c7f98be4bda1b4656b3a37cc13abe", [:mix], [], "hexpm", "796393a9e50d01999d56b7b8420ab0481a7538d0caf80919da493b4a6e51faf1"},
"credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"},
"credo_envvar": {:hex, :credo_envvar, "0.1.4", "40817c10334e400f031012c0510bfa0d8725c19d867e4ae39cf14f2cbebc3b20", [:mix], [{:credo, "~> 1.0", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "5055cdb4bcbaf7d423bc2bb3ac62b4e2d825e2b1e816884c468dee59d0363009"},
"credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"},
- "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"},
+ "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"},
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
"dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"},
- "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"},
- "ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"},
+ "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"},
+ "ecto": {:hex, :ecto, "3.12.1", "626765f7066589de6fa09e0876a253ff60c3d00870dd3a1cd696e2ba67bfceea", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df0045ab9d87be947228e05a8d153f3e06e0d05ab10c3b3cc557d2f7243d1940"},
"ecto_network": {:hex, :ecto_network, "1.5.0", "a930c910975e7a91237b858ebf0f4ad7b2aae32fa846275aa203cb858459ec73", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 0.0.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.14.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "4d614434ae3e6d373a2f693d56aafaa3f3349714668ffd6d24e760caf578aa2f"},
- "ecto_sql": {:hex, :ecto_sql, "3.11.2", "c7cc7f812af571e50b80294dc2e535821b3b795ce8008d07aa5f336591a185a8", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "73c07f995ac17dbf89d3cfaaf688fcefabcd18b7b004ac63b0dc4ef39499ed6b"},
+ "ecto_sql": {:hex, :ecto_sql, "3.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"},
"elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"},
"elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"},
- "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
+ "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"},
"ex_aws": {:git, "https://github.com/liamwhite/ex_aws.git", "a340859dd8ac4d63bd7a3948f0994e493e49bda4", [ref: "a340859dd8ac4d63bd7a3948f0994e493e49bda4"]},
"ex_aws_s3": {:hex, :ex_aws_s3, "2.5.3", "422468e5c3e1a4da5298e66c3468b465cfd354b842e512cb1f6fbbe4e2f5bdaf", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "4f09dd372cc386550e484808c5ac5027766c8d0cd8271ccc578b82ee6ef4f3b8"},
- "ex_doc": {:hex, :ex_doc, "0.34.0", "ab95e0775db3df71d30cf8d78728dd9261c355c81382bcd4cefdc74610bef13e", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "60734fb4c1353f270c3286df4a0d51e65a2c1d9fba66af3940847cc65a8066d7"},
- "expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"},
+ "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"},
+ "expo": {:hex, :expo, "1.0.0", "647639267e088717232f4d4451526e7a9de31a3402af7fcbda09b27e9a10395a", [:mix], [], "hexpm", "18d2093d344d97678e8a331ca0391e85d29816f9664a25653fd7e6166827827c"},
"exq": {:hex, :exq, "0.19.0", "06eb92944dad39f0954dc8f63190d3e24d11734eef88cf5800883e57ebf74f3c", [:mix], [{:elixir_uuid, ">= 1.2.0", [hex: :elixir_uuid, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, ">= 1.2.0 and < 6.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:redix, ">= 0.9.0", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "24fc0ebdd87cc7406e1034fb46c2419f9c8a362f0ec634d23b6b819514d36390"},
"file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"},
"finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"},
- "gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"},
- "hpax": {:hex, :hpax, "0.2.0", "5a58219adcb75977b2edce5eb22051de9362f08236220c9e859a47111c194ff5", [:mix], [], "hexpm", "bea06558cdae85bed075e6c036993d43cd54d447f76d8190a8db0dc5893fa2f1"},
- "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
+ "gettext": {:hex, :gettext, "0.25.0", "98a95a862a94e2d55d24520dd79256a15c87ea75b49673a2e2f206e6ebc42e5d", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "38e5d754e66af37980a94fb93bb20dcde1d2361f664b0a19f01e87296634051f"},
+ "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"},
"inet_cidr": {:hex, :inet_cidr, "1.0.8", "d26bb7bdbdf21ae401ead2092bf2bb4bf57fe44a62f5eaa5025280720ace8a40", [:mix], [], "hexpm", "d5b26da66603bb56c933c65214c72152f0de9a6ea53618b56d63302a68f6a90e"},
- "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"},
+ "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"mail": {:hex, :mail, "0.3.1", "cb0a14e4ed8904e4e5a08214e686ccf6f9099346885db17d8c309381f865cc5c", [:mix], [], "hexpm", "1db701e89865c1d5fa296b2b57b1cd587587cca8d8a1a22892b35ef5a8e352a6"},
"makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"},
"makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"},
- "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"},
- "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
- "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"},
- "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"},
- "mint": {:hex, :mint, "1.6.1", "065e8a5bc9bbd46a41099dfea3e0656436c5cbcb6e741c80bd2bad5cd872446f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4fc518dcc191d02f433393a72a7ba3f6f94b101d094cb6bf532ea54c89423780"},
- "mix_audit": {:hex, :mix_audit, "2.1.3", "c70983d5cab5dca923f9a6efe559abfb4ec3f8e87762f02bab00fa4106d17eda", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "8c3987100b23099aea2f2df0af4d296701efd031affb08d0746b2be9e35988ec"},
- "mua": {:hex, :mua, "0.2.2", "d2997abc1eee43d91e4a355665658743ad2609b8d5992425940ce17b7ff87933", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "cda7e38c65d3105b3017b25ac402b4c9457892abeb2e11c331b25a92d16b04c0"},
+ "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"},
+ "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
+ "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"},
+ "mix_audit": {:hex, :mix_audit, "2.1.4", "0a23d5b07350cdd69001c13882a4f5fb9f90fbd4cbf2ebc190a2ee0d187ea3e9", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "fd807653cc8c1cada2911129c7eb9e985e3cc76ebf26f4dd628bb25bbcaa7099"},
+ "mua": {:hex, :mua, "0.2.3", "46b29b7b2bb14105c0b7be9526f7c452df17a7841b30b69871c024a822ff551c", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "7fe861a87fcc06a980d3941bbcb2634e5f0f30fd6ad15ef6c0423ff9dc7e46de"},
"neotoma": {:hex, :neotoma, "1.7.3", "d8bd5404b73273989946e4f4f6d529e5c2088f5fa1ca790b4dbe81f4be408e61", [:rebar], [], "hexpm", "2da322b9b1567ffa0706a7f30f6bbbde70835ae44a1050615f4b4a3d436e0f28"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
- "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"pbkdf2": {:git, "https://github.com/basho/erlang-pbkdf2.git", "7e9bd5fcd3cc3062159e4c9214bb628aa6feb5ca", [ref: "7e9bd5fcd3cc3062159e4c9214bb628aa6feb5ca"]},
- "phoenix": {:hex, :phoenix, "1.7.12", "1cc589e0eab99f593a8aa38ec45f15d25297dd6187ee801c8de8947090b5a9d3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d646192fbade9f485b01bc9920c139bfdd19d0f8df3d73fd8eaf2dfbe0d2837c"},
- "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.1", "96798325fab2fed5a824ca204e877b81f9afd2e480f581e81f7b4b64a5a477f2", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.17", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "0ae544ff99f3c482b0807c5cec2c8289e810ecacabc04959d82c3337f4703391"},
+ "phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"},
+ "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.2", "3b83b24ab5a2eb071a20372f740d7118767c272db386831b2e77638c4dcc606d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "3f94d025f59de86be00f5f8c5dd7b5965a3298458d21ab1c328488be3b5fcd59"},
"phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
"phoenix_pubsub_redis": {:hex, :phoenix_pubsub_redis, "3.0.1", "d4d856b1e57a21358e448543e1d091e07e83403dde4383b8be04ed9d2c201cbc", [:mix], [{:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5.1 or ~> 1.6", [hex: :poolboy, repo: "hexpm", optional: false]}, {:redix, "~> 0.10.0 or ~> 1.0", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "0b36a17ff6e9a56159f8df8933d62b5c1f0695eae995a02e0c86c035ace6a309"},
"phoenix_slime": {:git, "https://github.com/slime-lang/phoenix_slime.git", "8944de91654d6fcf6bdcc0aed6b8647fe3398241", [ref: "8944de91654d6fcf6bdcc0aed6b8647fe3398241"]},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
- "phoenix_view": {:hex, :phoenix_view, "2.0.3", "4d32c4817fce933693741deeb99ef1392619f942633dde834a5163124813aad3", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "cd34049af41be2c627df99cd4eaa71fc52a328c0c3d8e7d4aa28f880c30e7f64"},
- "plug": {:hex, :plug, "1.16.0", "1d07d50cb9bb05097fdf187b31cf087c7297aafc3fed8299aac79c128a707e47", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbf53aa1f5c4d758a7559c0bd6d59e286c2be0c6a1fac8cc3eee2f638243b93e"},
+ "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
+ "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
- "postgrex": {:hex, :postgrex, "0.18.0", "f34664101eaca11ff24481ed4c378492fed2ff416cd9b06c399e90f321867d7e", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a042989ba1bc1cca7383ebb9e461398e3f89f868c92ce6671feb7ef132a252d1"},
+ "postgrex": {:hex, :postgrex, "0.19.1", "73b498508b69aded53907fe48a1fee811be34cc720e69ef4ccd568c8715495ea", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8bac7885a18f381e091ec6caf41bda7bb8c77912bb0e9285212829afe5d8a8f8"},
"pot": {:hex, :pot, "1.0.2", "13abb849139fdc04ab8154986abbcb63bdee5de6ed2ba7e1713527e33df923dd", [:rebar3], [], "hexpm", "78fe127f5a4f5f919d6ea5a2a671827bd53eb9d37e5b4128c0ad3df99856c2e0"},
"qrcode": {:hex, :qrcode, "0.1.5", "551271830515c150f34568345b060c625deb0e6691db2a01b0a6de3aafc93886", [:mix], [], "hexpm", "a266b7fb7be0d3b713912055dde3575927eca920e5d604ded45cd534f6b7a447"},
"redix": {:hex, :redix, "1.5.1", "a2386971e69bf23630fb3a215a831b5478d2ee7dc9ea7ac811ed89186ab5d7b7", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "85224eb2b683c516b80d472eb89b76067d5866913bf0be59d646f550de71f5c4"},
"remote_ip": {:hex, :remote_ip, "1.2.0", "fb078e12a44414f4cef5a75963c33008fe169b806572ccd17257c208a7bc760f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2ff91de19c48149ce19ed230a81d377186e4412552a597d6a5137373e5877cb7"},
- "req": {:hex, :req, "0.5.0", "6d8a77c25cfc03e06a439fb12ffb51beade53e3fe0e2c5e362899a18b50298b3", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "dda04878c1396eebbfdec6db6f3d4ca609e5c8846b7ee88cc56eb9891406f7a3"},
- "retry": {:hex, :retry, "0.18.0", "dc58ebe22c95aa00bc2459f9e0c5400e6005541cf8539925af0aa027dc860543", [:mix], [], "hexpm", "9483959cc7bf69c9e576d9dfb2b678b71c045d3e6f39ab7c9aa1489df4492d73"},
- "rustler": {:hex, :rustler, "0.33.0", "4a5b0a7a7b0b51549bea49947beff6fae9bc5d5326104dcd4531261e876b5619", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "7c4752728fee59a815ffd20c3429c55b644041f25129b29cdeb5c470b80ec5fd"},
+ "req": {:hex, :req, "0.5.6", "8fe1eead4a085510fe3d51ad854ca8f20a622aae46e97b302f499dfb84f726ac", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cfaa8e720945d46654853de39d368f40362c2641c4b2153c886418914b372185"},
+ "rustler": {:hex, :rustler, "0.34.0", "e9a73ee419fc296a10e49b415a2eb87a88c9217aa0275ec9f383d37eed290c1c", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "1d0c7449482b459513003230c0e2422b0252245776fe6fd6e41cb2b11bd8e628"},
"scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"},
- "scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"},
+ "scrivener_ecto": {:git, "https://github.com/krns/scrivener_ecto.git", "eaad1ddd86a9c8ffa422479417221265a0673777", [ref: "eaad1ddd86a9c8ffa422479417221265a0673777"]},
"secure_compare": {:hex, :secure_compare, "0.1.0", "01b3c93c8edb696e8a5b38397ed48e10958c8a5ec740606656445bcbec0aadb8", [:mix], [], "hexpm", "6391a49eb4a6182f0d7425842fc774bbed715e78b2bfb0c83b99c94e02c78b5c"},
"slime": {:hex, :slime, "1.3.1", "d6781854092a638e451427c33e67be348352651a7917a128155b8a41ac88d0a2", [:mix], [{:neotoma, "~> 1.7", [hex: :neotoma, repo: "hexpm", optional: false]}], "hexpm", "099b09280297e0c6c8d1f56b0033b885fc4eb541ad3c4a75f88a589354e2501b"},
"sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"},
- "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"},
- "swoosh": {:hex, :swoosh, "1.16.9", "20c6a32ea49136a4c19f538e27739bb5070558c0fa76b8a95f4d5d5ca7d319a1", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.0", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "878b1a7a6c10ebbf725a3349363f48f79c5e3d792eb621643b0d276a38acc0a6"},
+ "swoosh": {:hex, :swoosh, "1.16.10", "04be6e2eb1a31aa0aa21a731175c81cc3998189456a92daf13d44a5c754afcf5", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "756be04db173c0cbe318f1dfe2bcc88aa63aed78cf5a4b02b61b36ee11fc716a"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
"thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"},
"toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"},
- "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
- "websock_adapter": {:hex, :websock_adapter, "0.5.6", "0437fe56e093fd4ac422de33bf8fc89f7bc1416a3f2d732d8b2c8fd54792fe60", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "e04378d26b0af627817ae84c92083b7e97aca3121196679b73c73b99d0d133ea"},
+ "websock_adapter": {:hex, :websock_adapter, "0.5.7", "65fa74042530064ef0570b75b43f5c49bb8b235d6515671b3d250022cb8a1f9e", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d0f478ee64deddfec64b800673fd6e0c8888b079d9f3444dd96d2a98383bdbd1"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
- "yaml_elixir": {:hex, :yaml_elixir, "2.9.0", "9a256da867b37b8d2c1ffd5d9de373a4fda77a32a45b452f1708508ba7bbcb53", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "0cb0e7d4c56f5e99a6253ed1a670ed0e39c13fc45a6da054033928607ac08dfc"},
+ "yaml_elixir": {:hex, :yaml_elixir, "2.11.0", "9e9ccd134e861c66b84825a3542a1c22ba33f338d82c07282f4f1f52d847bd50", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "53cc28357ee7eb952344995787f4bb8cc3cecbf189652236e9b163e8ce1bc242"},
}
diff --git a/priv/repo/migrations/20240723122759_add_images_orig_size.exs b/priv/repo/migrations/20240723122759_add_images_orig_size.exs
new file mode 100644
index 00000000..e41ff7af
--- /dev/null
+++ b/priv/repo/migrations/20240723122759_add_images_orig_size.exs
@@ -0,0 +1,9 @@
+defmodule Philomena.Repo.Migrations.AddImagesOrigSize do
+ use Ecto.Migration
+
+ def change do
+ alter table("images") do
+ add :image_orig_size, :integer
+ end
+ end
+end
diff --git a/priv/repo/migrations/20240728191353_new_notifications.exs b/priv/repo/migrations/20240728191353_new_notifications.exs
new file mode 100644
index 00000000..8ebfd890
--- /dev/null
+++ b/priv/repo/migrations/20240728191353_new_notifications.exs
@@ -0,0 +1,109 @@
+defmodule Philomena.Repo.Migrations.NewNotifications do
+ use Ecto.Migration
+
+ @categories [
+ channel_live: [channels: :channel_id],
+ forum_post: [topics: :topic_id, posts: :post_id],
+ forum_topic: [topics: :topic_id],
+ gallery_image: [galleries: :gallery_id],
+ image_comment: [images: :image_id, comments: :comment_id],
+ image_merge: [images: :target_id, images: :source_id]
+ ]
+
+ def up do
+ for {category, refs} <- @categories do
+ create table("#{category}_notifications", primary_key: false) do
+ for {target_table_name, reference_name} <- refs do
+ add reference_name, references(target_table_name, on_delete: :delete_all), null: false
+ end
+
+ add :user_id, references(:users, on_delete: :delete_all), null: false
+ timestamps(inserted_at: :created_at, type: :utc_datetime)
+ add :read, :boolean, default: false, null: false
+ end
+
+ {_primary_table_name, primary_ref_name} = hd(refs)
+ create index("#{category}_notifications", [:user_id, primary_ref_name], unique: true)
+ create index("#{category}_notifications", [:user_id, "updated_at desc"])
+ create index("#{category}_notifications", [:user_id, :read])
+
+ for {_target_table_name, reference_name} <- refs do
+ create index("#{category}_notifications", [reference_name])
+ end
+ end
+
+ insert_statements =
+ """
+ insert into channel_live_notifications (channel_id, user_id, created_at, updated_at)
+ select n.actor_id, un.user_id, n.created_at, n.updated_at
+ from unread_notifications un
+ join notifications n on un.notification_id = n.id
+ where n.actor_type = 'Channel'
+ and exists(select 1 from channels c where c.id = n.actor_id)
+ and exists(select 1 from users u where u.id = un.user_id);
+
+ insert into forum_post_notifications (topic_id, post_id, user_id, created_at, updated_at)
+ select n.actor_id, n.actor_child_id, un.user_id, n.created_at, n.updated_at
+ from unread_notifications un
+ join notifications n on un.notification_id = n.id
+ where n.actor_type = 'Topic'
+ and n.actor_child_type = 'Post'
+ and n.action = 'posted a new reply in'
+ and exists(select 1 from topics t where t.id = n.actor_id)
+ and exists(select 1 from posts p where p.id = n.actor_child_id)
+ and exists(select 1 from users u where u.id = un.user_id);
+
+ insert into forum_topic_notifications (topic_id, user_id, created_at, updated_at)
+ select n.actor_id, un.user_id, n.created_at, n.updated_at
+ from unread_notifications un
+ join notifications n on un.notification_id = n.id
+ where n.actor_type = 'Topic'
+ and n.actor_child_type = 'Post'
+ and n.action <> 'posted a new reply in'
+ and exists(select 1 from topics t where t.id = n.actor_id)
+ and exists(select 1 from users u where u.id = un.user_id);
+
+ insert into gallery_image_notifications (gallery_id, user_id, created_at, updated_at)
+ select n.actor_id, un.user_id, n.created_at, n.updated_at
+ from unread_notifications un
+ join notifications n on un.notification_id = n.id
+ where n.actor_type = 'Gallery'
+ and exists(select 1 from galleries g where g.id = n.actor_id)
+ and exists(select 1 from users u where u.id = un.user_id);
+
+ insert into image_comment_notifications (image_id, comment_id, user_id, created_at, updated_at)
+ select n.actor_id, n.actor_child_id, un.user_id, n.created_at, n.updated_at
+ from unread_notifications un
+ join notifications n on un.notification_id = n.id
+ where n.actor_type = 'Image'
+ and n.actor_child_type = 'Comment'
+ and exists(select 1 from images i where i.id = n.actor_id)
+ and exists(select 1 from comments c where c.id = n.actor_child_id)
+ and exists(select 1 from users u where u.id = un.user_id);
+
+ insert into image_merge_notifications (target_id, source_id, user_id, created_at, updated_at)
+ select n.actor_id, regexp_replace(n.action, '[a-z#]+', '', 'g')::bigint, un.user_id, n.created_at, n.updated_at
+ from unread_notifications un
+ join notifications n on un.notification_id = n.id
+ where n.actor_type = 'Image'
+ and n.actor_child_type is null
+ and exists(select 1 from images i where i.id = n.actor_id)
+ and exists(select 1 from images i where i.id = regexp_replace(n.action, '[a-z#]+', '', 'g')::integer)
+ and exists(select 1 from users u where u.id = un.user_id);
+ """
+
+ # These statements should not be run by the migration in production.
+ # Run them manually in psql instead.
+ if System.get_env("MIX_ENV") != "prod" do
+ for stmt <- String.split(insert_statements, "\n\n") do
+ execute(stmt)
+ end
+ end
+ end
+
+ def down do
+ for {category, _refs} <- @categories do
+ drop table("#{category}_notifications")
+ end
+ end
+end
diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql
index ab137a7c..49678d33 100644
--- a/priv/repo/structure.sql
+++ b/priv/repo/structure.sql
@@ -2,8 +2,8 @@
-- PostgreSQL database dump
--
--- Dumped from database version 14.1
--- Dumped by pg_dump version 14.1
+-- Dumped from database version 16.3
+-- Dumped by pg_dump version 16.3
SET statement_timeout = 0;
SET lock_timeout = 0;
@@ -198,6 +198,19 @@ CREATE SEQUENCE public.badges_id_seq
ALTER SEQUENCE public.badges_id_seq OWNED BY public.badges.id;
+--
+-- Name: channel_live_notifications; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.channel_live_notifications (
+ channel_id bigint NOT NULL,
+ user_id bigint NOT NULL,
+ created_at timestamp(0) without time zone NOT NULL,
+ updated_at timestamp(0) without time zone NOT NULL,
+ read boolean DEFAULT false NOT NULL
+);
+
+
--
-- Name: channel_subscriptions; Type: TABLE; Schema: public; Owner: -
--
@@ -620,6 +633,20 @@ CREATE SEQUENCE public.fingerprint_bans_id_seq
ALTER SEQUENCE public.fingerprint_bans_id_seq OWNED BY public.fingerprint_bans.id;
+--
+-- Name: forum_post_notifications; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.forum_post_notifications (
+ topic_id bigint NOT NULL,
+ post_id bigint NOT NULL,
+ user_id bigint NOT NULL,
+ created_at timestamp(0) without time zone NOT NULL,
+ updated_at timestamp(0) without time zone NOT NULL,
+ read boolean DEFAULT false NOT NULL
+);
+
+
--
-- Name: forum_subscriptions; Type: TABLE; Schema: public; Owner: -
--
@@ -630,6 +657,19 @@ CREATE TABLE public.forum_subscriptions (
);
+--
+-- Name: forum_topic_notifications; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.forum_topic_notifications (
+ topic_id bigint NOT NULL,
+ user_id bigint NOT NULL,
+ created_at timestamp(0) without time zone NOT NULL,
+ updated_at timestamp(0) without time zone NOT NULL,
+ read boolean DEFAULT false NOT NULL
+);
+
+
--
-- Name: forums; Type: TABLE; Schema: public; Owner: -
--
@@ -709,6 +749,19 @@ CREATE SEQUENCE public.galleries_id_seq
ALTER SEQUENCE public.galleries_id_seq OWNED BY public.galleries.id;
+--
+-- Name: gallery_image_notifications; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.gallery_image_notifications (
+ gallery_id bigint NOT NULL,
+ user_id bigint NOT NULL,
+ created_at timestamp(0) without time zone NOT NULL,
+ updated_at timestamp(0) without time zone NOT NULL,
+ read boolean DEFAULT false NOT NULL
+);
+
+
--
-- Name: gallery_interactions; Type: TABLE; Schema: public; Owner: -
--
@@ -750,6 +803,20 @@ CREATE TABLE public.gallery_subscriptions (
);
+--
+-- Name: image_comment_notifications; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.image_comment_notifications (
+ image_id bigint NOT NULL,
+ comment_id bigint NOT NULL,
+ user_id bigint NOT NULL,
+ created_at timestamp(0) without time zone NOT NULL,
+ updated_at timestamp(0) without time zone NOT NULL,
+ read boolean DEFAULT false NOT NULL
+);
+
+
--
-- Name: image_faves; Type: TABLE; Schema: public; Owner: -
--
@@ -837,6 +904,20 @@ CREATE SEQUENCE public.image_intensities_id_seq
ALTER SEQUENCE public.image_intensities_id_seq OWNED BY public.image_intensities.id;
+--
+-- Name: image_merge_notifications; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.image_merge_notifications (
+ target_id bigint NOT NULL,
+ source_id bigint NOT NULL,
+ user_id bigint NOT NULL,
+ created_at timestamp(0) without time zone NOT NULL,
+ updated_at timestamp(0) without time zone NOT NULL,
+ read boolean DEFAULT false NOT NULL
+);
+
+
--
-- Name: image_sources; Type: TABLE; Schema: public; Owner: -
--
@@ -953,7 +1034,8 @@ CREATE TABLE public.images (
image_duration double precision,
description character varying DEFAULT ''::character varying NOT NULL,
scratchpad character varying,
- approved boolean DEFAULT false
+ approved boolean DEFAULT false,
+ image_orig_size integer
);
@@ -2893,6 +2975,160 @@ ALTER TABLE ONLY public.versions
ADD CONSTRAINT versions_pkey PRIMARY KEY (id);
+--
+-- Name: channel_live_notifications_channel_id_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX channel_live_notifications_channel_id_index ON public.channel_live_notifications USING btree (channel_id);
+
+
+--
+-- Name: channel_live_notifications_user_id_channel_id_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE UNIQUE INDEX channel_live_notifications_user_id_channel_id_index ON public.channel_live_notifications USING btree (user_id, channel_id);
+
+
+--
+-- Name: channel_live_notifications_user_id_read_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX channel_live_notifications_user_id_read_index ON public.channel_live_notifications USING btree (user_id, read);
+
+
+--
+-- Name: channel_live_notifications_user_id_updated_at_desc_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX channel_live_notifications_user_id_updated_at_desc_index ON public.channel_live_notifications USING btree (user_id, updated_at DESC);
+
+
+--
+-- Name: forum_post_notifications_post_id_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX forum_post_notifications_post_id_index ON public.forum_post_notifications USING btree (post_id);
+
+
+--
+-- Name: forum_post_notifications_topic_id_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX forum_post_notifications_topic_id_index ON public.forum_post_notifications USING btree (topic_id);
+
+
+--
+-- Name: forum_post_notifications_user_id_read_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX forum_post_notifications_user_id_read_index ON public.forum_post_notifications USING btree (user_id, read);
+
+
+--
+-- Name: forum_post_notifications_user_id_topic_id_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE UNIQUE INDEX forum_post_notifications_user_id_topic_id_index ON public.forum_post_notifications USING btree (user_id, topic_id);
+
+
+--
+-- Name: forum_post_notifications_user_id_updated_at_desc_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX forum_post_notifications_user_id_updated_at_desc_index ON public.forum_post_notifications USING btree (user_id, updated_at DESC);
+
+
+--
+-- Name: forum_topic_notifications_topic_id_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX forum_topic_notifications_topic_id_index ON public.forum_topic_notifications USING btree (topic_id);
+
+
+--
+-- Name: forum_topic_notifications_user_id_read_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX forum_topic_notifications_user_id_read_index ON public.forum_topic_notifications USING btree (user_id, read);
+
+
+--
+-- Name: forum_topic_notifications_user_id_topic_id_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE UNIQUE INDEX forum_topic_notifications_user_id_topic_id_index ON public.forum_topic_notifications USING btree (user_id, topic_id);
+
+
+--
+-- Name: forum_topic_notifications_user_id_updated_at_desc_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX forum_topic_notifications_user_id_updated_at_desc_index ON public.forum_topic_notifications USING btree (user_id, updated_at DESC);
+
+
+--
+-- Name: gallery_image_notifications_gallery_id_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX gallery_image_notifications_gallery_id_index ON public.gallery_image_notifications USING btree (gallery_id);
+
+
+--
+-- Name: gallery_image_notifications_user_id_gallery_id_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE UNIQUE INDEX gallery_image_notifications_user_id_gallery_id_index ON public.gallery_image_notifications USING btree (user_id, gallery_id);
+
+
+--
+-- Name: gallery_image_notifications_user_id_read_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX gallery_image_notifications_user_id_read_index ON public.gallery_image_notifications USING btree (user_id, read);
+
+
+--
+-- Name: gallery_image_notifications_user_id_updated_at_desc_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX gallery_image_notifications_user_id_updated_at_desc_index ON public.gallery_image_notifications USING btree (user_id, updated_at DESC);
+
+
+--
+-- Name: image_comment_notifications_comment_id_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX image_comment_notifications_comment_id_index ON public.image_comment_notifications USING btree (comment_id);
+
+
+--
+-- Name: image_comment_notifications_image_id_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX image_comment_notifications_image_id_index ON public.image_comment_notifications USING btree (image_id);
+
+
+--
+-- Name: image_comment_notifications_user_id_image_id_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE UNIQUE INDEX image_comment_notifications_user_id_image_id_index ON public.image_comment_notifications USING btree (user_id, image_id);
+
+
+--
+-- Name: image_comment_notifications_user_id_read_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX image_comment_notifications_user_id_read_index ON public.image_comment_notifications USING btree (user_id, read);
+
+
+--
+-- Name: image_comment_notifications_user_id_updated_at_desc_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX image_comment_notifications_user_id_updated_at_desc_index ON public.image_comment_notifications USING btree (user_id, updated_at DESC);
+
+
--
-- Name: image_intensities_index; Type: INDEX; Schema: public; Owner: -
--
@@ -2900,6 +3136,41 @@ ALTER TABLE ONLY public.versions
CREATE INDEX image_intensities_index ON public.image_intensities USING btree (nw, ne, sw, se);
+--
+-- Name: image_merge_notifications_source_id_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX image_merge_notifications_source_id_index ON public.image_merge_notifications USING btree (source_id);
+
+
+--
+-- Name: image_merge_notifications_target_id_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX image_merge_notifications_target_id_index ON public.image_merge_notifications USING btree (target_id);
+
+
+--
+-- Name: image_merge_notifications_user_id_read_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX image_merge_notifications_user_id_read_index ON public.image_merge_notifications USING btree (user_id, read);
+
+
+--
+-- Name: image_merge_notifications_user_id_target_id_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE UNIQUE INDEX image_merge_notifications_user_id_target_id_index ON public.image_merge_notifications USING btree (user_id, target_id);
+
+
+--
+-- Name: image_merge_notifications_user_id_updated_at_desc_index; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX image_merge_notifications_user_id_updated_at_desc_index ON public.image_merge_notifications USING btree (user_id, updated_at DESC);
+
+
--
-- Name: image_sources_image_id_source_index; Type: INDEX; Schema: public; Owner: -
--
@@ -4174,6 +4445,22 @@ CREATE UNIQUE INDEX user_tokens_context_token_index ON public.user_tokens USING
CREATE INDEX user_tokens_user_id_index ON public.user_tokens USING btree (user_id);
+--
+-- Name: channel_live_notifications channel_live_notifications_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.channel_live_notifications
+ ADD CONSTRAINT channel_live_notifications_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE;
+
+
+--
+-- Name: channel_live_notifications channel_live_notifications_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.channel_live_notifications
+ ADD CONSTRAINT channel_live_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
+
+
--
-- Name: channels fk_rails_021c624081; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -4966,6 +5253,110 @@ ALTER TABLE ONLY public.gallery_subscriptions
ADD CONSTRAINT fk_rails_fa77f3cebe FOREIGN KEY (gallery_id) REFERENCES public.galleries(id) ON UPDATE CASCADE ON DELETE CASCADE;
+--
+-- Name: forum_post_notifications forum_post_notifications_post_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.forum_post_notifications
+ ADD CONSTRAINT forum_post_notifications_post_id_fkey FOREIGN KEY (post_id) REFERENCES public.posts(id) ON DELETE CASCADE;
+
+
+--
+-- Name: forum_post_notifications forum_post_notifications_topic_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.forum_post_notifications
+ ADD CONSTRAINT forum_post_notifications_topic_id_fkey FOREIGN KEY (topic_id) REFERENCES public.topics(id) ON DELETE CASCADE;
+
+
+--
+-- Name: forum_post_notifications forum_post_notifications_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.forum_post_notifications
+ ADD CONSTRAINT forum_post_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
+
+
+--
+-- Name: forum_topic_notifications forum_topic_notifications_topic_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.forum_topic_notifications
+ ADD CONSTRAINT forum_topic_notifications_topic_id_fkey FOREIGN KEY (topic_id) REFERENCES public.topics(id) ON DELETE CASCADE;
+
+
+--
+-- Name: forum_topic_notifications forum_topic_notifications_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.forum_topic_notifications
+ ADD CONSTRAINT forum_topic_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
+
+
+--
+-- Name: gallery_image_notifications gallery_image_notifications_gallery_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.gallery_image_notifications
+ ADD CONSTRAINT gallery_image_notifications_gallery_id_fkey FOREIGN KEY (gallery_id) REFERENCES public.galleries(id) ON DELETE CASCADE;
+
+
+--
+-- Name: gallery_image_notifications gallery_image_notifications_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.gallery_image_notifications
+ ADD CONSTRAINT gallery_image_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
+
+
+--
+-- Name: image_comment_notifications image_comment_notifications_comment_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.image_comment_notifications
+ ADD CONSTRAINT image_comment_notifications_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES public.comments(id) ON DELETE CASCADE;
+
+
+--
+-- Name: image_comment_notifications image_comment_notifications_image_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.image_comment_notifications
+ ADD CONSTRAINT image_comment_notifications_image_id_fkey FOREIGN KEY (image_id) REFERENCES public.images(id) ON DELETE CASCADE;
+
+
+--
+-- Name: image_comment_notifications image_comment_notifications_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.image_comment_notifications
+ ADD CONSTRAINT image_comment_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
+
+
+--
+-- Name: image_merge_notifications image_merge_notifications_source_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.image_merge_notifications
+ ADD CONSTRAINT image_merge_notifications_source_id_fkey FOREIGN KEY (source_id) REFERENCES public.images(id) ON DELETE CASCADE;
+
+
+--
+-- Name: image_merge_notifications image_merge_notifications_target_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.image_merge_notifications
+ ADD CONSTRAINT image_merge_notifications_target_id_fkey FOREIGN KEY (target_id) REFERENCES public.images(id) ON DELETE CASCADE;
+
+
+--
+-- Name: image_merge_notifications image_merge_notifications_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.image_merge_notifications
+ ADD CONSTRAINT image_merge_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
+
+
--
-- Name: image_sources image_sources_image_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -4996,6 +5387,9 @@ ALTER TABLE ONLY public.image_tag_locks
ALTER TABLE ONLY public.moderation_logs
ADD CONSTRAINT moderation_logs_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
+
+
+--
-- Name: source_changes source_changes_image_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -5051,3 +5445,5 @@ INSERT INTO public."schema_migrations" (version) VALUES (20211009011024);
INSERT INTO public."schema_migrations" (version) VALUES (20211107130226);
INSERT INTO public."schema_migrations" (version) VALUES (20211219194836);
INSERT INTO public."schema_migrations" (version) VALUES (20220321173359);
+INSERT INTO public."schema_migrations" (version) VALUES (20240723122759);
+INSERT INTO public."schema_migrations" (version) VALUES (20240728191353);