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
-
- - -
-
- -
-
`, +
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', - ` -
+ `
@@ -78,9 +90,13 @@ describe('Image upload form', () => { +
+ -
- `, +
+ +
+ `, ); 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);