From 563e32316f808995fe2500286ca8df8954ff8c14 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Wed, 12 Feb 2025 14:12:13 -0500 Subject: [PATCH] factor out common append-content-warning routine for use in both frontend and backend --- .../src/misc/append-content-warning.ts | 62 +++++++++++++ .../test/unit/misc/append-content-warning.ts | 92 +++++++++++++++++++ .../js/append-content-warning.ts | 62 +++++++++++++ .../frontend/src/components/MkPostForm.vue | 15 +-- 4 files changed, 219 insertions(+), 12 deletions(-) create mode 100644 packages/backend/src/misc/append-content-warning.ts create mode 100644 packages/backend/test/unit/misc/append-content-warning.ts create mode 100644 packages/frontend-shared/js/append-content-warning.ts diff --git a/packages/backend/src/misc/append-content-warning.ts b/packages/backend/src/misc/append-content-warning.ts new file mode 100644 index 0000000000..152cd6760e --- /dev/null +++ b/packages/backend/src/misc/append-content-warning.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* + * Important Note: this file must be kept in sync with packages/frontend-shared/js/append-content-warning.ts + */ + +/** + * Appends an additional content warning onto an existing one. + * The additional value will not be added if it already exists within the original input. + * @param original Existing content warning + * @param additional Content warning to append + * @param reverse If true, then the additional CW will be prepended instead of appended. + */ +export function appendContentWarning(original: string | null | undefined, additional: string, reverse = false): string { + // Easy case - if original is empty, then additional replaces it. + if (!original) { + return additional; + } + + // Easy case - if the additional CW is empty, then don't append it. + if (!additional) { + return original; + } + + // If the additional CW already exists in the input, then we *don't* append another copy! + if (includesWholeWord(original, additional)) { + return original; + } + + return reverse + ? `${additional}, ${original}` + : `${original}, ${additional}`; +} + +/** + * Emulates a regular expression like /\b(pattern)\b/, but with a raw non-regex pattern. + * We're checking to see whether the default CW appears inside the existing CW, but *only* if there's word boundaries on either side. + * @param input Input string to search + * @param target Target word / phrase to search for + */ +function includesWholeWord(input: string, target: string): boolean { + const parts = input.split(target); + + // The additional string could appear multiple times within the original input. + // We need to check each occurrence, since any of them could potentially match. + for (let i = 0; i + 1 < parts.length; i++) { + const before = parts[i]; + const after = parts[i + 1]; + + // If either the preceding or following tokens are a "word", then this "match" is actually just part of a longer word. + // Likewise, if *neither* token is a word, then this is a real match and the CW already exists in the input. + if (!/\w$/.test(before) && !/^\w/.test(after)) { + return true; + } + } + + // If we don't match, then there is no existing CW. + return false; +} diff --git a/packages/backend/test/unit/misc/append-content-warning.ts b/packages/backend/test/unit/misc/append-content-warning.ts new file mode 100644 index 0000000000..d25d7c4925 --- /dev/null +++ b/packages/backend/test/unit/misc/append-content-warning.ts @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { appendContentWarning } from '@/misc/append-content-warning.js'; + +describe(appendContentWarning, () => { + it('should return additional when original is null', () => { + const result = appendContentWarning(null, 'additional'); + + expect(result).toBe('additional'); + }); + + it('should return additional when original is undefined', () => { + const result = appendContentWarning(undefined, 'additional'); + + expect(result).toBe('additional'); + }); + + it('should return additional when original is empty', () => { + const result = appendContentWarning('', 'additional'); + + expect(result).toBe('additional'); + }); + + it('should return original when additional is empty', () => { + const result = appendContentWarning('original', ''); + + expect(result).toBe('original'); + }); + + it('should append additional when it does not exist in original', () => { + const result = appendContentWarning('original', 'additional'); + + expect(result).toBe('original, additional'); + }); + + it('should append additional when it exists in original but has preceeding word', () => { + const result = appendContentWarning('notadditional', 'additional'); + + expect(result).toBe('notadditional, additional'); + }); + + it('should append additional when it exists in original but has following word', () => { + const result = appendContentWarning('additionalnot', 'additional'); + + expect(result).toBe('additionalnot, additional'); + }); + + it('should append additional when it exists in original multiple times but has preceeding or following word', () => { + const result = appendContentWarning('notadditional additionalnot', 'additional'); + + expect(result).toBe('notadditional additionalnot, additional'); + }); + + it('should not append additional when it exists in original', () => { + const result = appendContentWarning('an additional word', 'additional'); + + expect(result).toBe('an additional word'); + }); + + it('should not append additional when original starts with it', () => { + const result = appendContentWarning('additional word', 'additional'); + + expect(result).toBe('additional word'); + }); + + it('should not append additional when original ends with it', () => { + const result = appendContentWarning('an additional', 'additional'); + + expect(result).toBe('an additional'); + }); + + it('should not append additional when it appears multiple times', () => { + const result = appendContentWarning('an additional additional word', 'additional'); + + expect(result).toBe('an additional additional word'); + }); + + it('should not append additional when it appears multiple times but some have preceeding or following', () => { + const result = appendContentWarning('a notadditional additional additionalnot word', 'additional'); + + expect(result).toBe('a notadditional additional additionalnot word'); + }); + + it('should prepend additional when reverse is true', () => { + const result = appendContentWarning('original', 'additional', true); + + expect(result).toBe('additional, original'); + }); +}); diff --git a/packages/frontend-shared/js/append-content-warning.ts b/packages/frontend-shared/js/append-content-warning.ts new file mode 100644 index 0000000000..7f24a66f23 --- /dev/null +++ b/packages/frontend-shared/js/append-content-warning.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* + * Important Note: this file must be kept in sync with packages/backend/src/misc/append-content-warning.ts + */ + +/** + * Appends an additional content warning onto an existing one. + * The additional value will not be added if it already exists within the original input. + * @param original Existing content warning + * @param additional Content warning to append + * @param reverse If true, then the additional CW will be prepended instead of appended. + */ +export function appendContentWarning(original: string | null | undefined, additional: string, reverse = false): string { + // Easy case - if original is empty, then additional replaces it. + if (!original) { + return additional; + } + + // Easy case - if the additional CW is empty, then don't append it. + if (!additional) { + return original; + } + + // If the additional CW already exists in the input, then we *don't* append another copy! + if (includesWholeWord(original, additional)) { + return original; + } + + return reverse + ? `${additional}, ${original}` + : `${original}, ${additional}`; +} + +/** + * Emulates a regular expression like /\b(pattern)\b/, but with a raw non-regex pattern. + * We're checking to see whether the default CW appears inside the existing CW, but *only* if there's word boundaries on either side. + * @param input Input string to search + * @param target Target word / phrase to search for + */ +function includesWholeWord(input: string, target: string): boolean { + const parts = input.split(target); + + // The additional string could appear multiple times within the original input. + // We need to check each occurrence, since any of them could potentially match. + for (let i = 0; i + 1 < parts.length; i++) { + const before = parts[i]; + const after = parts[i + 1]; + + // If either the preceding or following tokens are a "word", then this "match" is actually just part of a longer word. + // Likewise, if *neither* token is a word, then this is a real match and the CW already exists in the input. + if (!/\w$/.test(before) && !/^\w/.test(after)) { + return true; + } + } + + // If we don't match, then there is no existing CW. + return false; +} diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 059de8011c..c5f5f4514d 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -112,6 +112,7 @@ import * as Misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; import { toASCII } from 'punycode.js'; import { host, url } from '@@/js/config.js'; +import { appendContentWarning } from '@@/js/append-content-warning.js'; import type { MenuItem } from '@/types/menu.js'; import type { PostFormProps } from '@/types/post-form.js'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; @@ -373,18 +374,8 @@ if ($i.defaultCW) { if (!cw.value || $i.defaultCWPriority === 'default') { cw.value = $i.defaultCW; } else if ($i.defaultCWPriority !== 'parent') { - // This is a fancy way of simulating /\bsearch\b/ without a regular expression. - // We're checking to see whether the default CW appears inside the existing CW, but *only* if there's word boundaries. - const parts = cw.value.split($i.defaultCW); - const hasExistingDefaultCW = parts.length === 2 && !/\w$/.test(parts[0]) && !/^\w/.test(parts[1]); - if (!hasExistingDefaultCW) { - // We need to merge the CWs - if ($i.defaultCWPriority === 'defaultParent') { - cw.value = `${$i.defaultCW}, ${cw.value}`; - } else if ($i.defaultCWPriority === 'parentDefault') { - cw.value = `${cw.value}, ${$i.defaultCW}`; - } - } + const putDefaultFirst = $i.defaultCWPriority === 'defaultParent'; + cw.value = appendContentWarning(cw.value, $i.defaultCW, putDefaultFirst); } // else { do nothing, because existing CW takes priority. } }