Repository: web-scrobbler/web-scrobbler
Pull request #4455 (2024-01-26 23:13:54)

Add regex flags and allow replace with empty
Draft for now: testing of UI is still a bit light (though unit tests have been added for application of the edits themselves and I think I'm reasonably satisfied with those). UI design I'm also not completely sure of, would appreciate feedback!

Adds flags to enable/disable regex, enable/disable case sensitivity, enable/disable having to match entire tag to apply. Also adds a button to allow user to actually replace with nothing, instead of just empty text field which makes it just apply the search without doing any replace.

Is built on top of the old system and is completely backwards-compatible with old regex/bulk edits, without any migration needed. For this reason, internal flag names are a bit odd.

**UI:**

Desktop:
<img width="785" alt="image" src="https://github.com/web-scrobbler/web-scrobbler/assets/69117606/193ae687-fdf1-4adb-92bb-0268d559349f">

iOS:
<img width="260" alt="image" src="https://github.com/web-scrobbler/web-scrobbler/assets/69117606/5670f783-eaa8-4492-a4ba-ce1d4b8accd5">

iPadOS:
<img width="166" alt="image" src="https://github.com/web-scrobbler/web-scrobbler/assets/69117606/f9130373-71e8-4d32-ace9-dec7eaeb4329">



yayuyokitano: Changed bulk edit icon, as old was not obvious.

<img width="409" alt="image" src="https://github.com/web-scrobbler/web-scrobbler/assets/69117606/3de9f5ca-8e2c-4307-90bc-3d0eb451afaf">
yayuyokitano: This PR also drastically speeds up tests through mocking fetch API.
yayuyokitano: For some reason new test seems to be flaky in CI.
Cannot reproduce it in local at all
yayuyokitano: now i cant repro in ci either
yayuyokitano: Keep retrying this but it just won’t fail again now that I added logic to force traceable throwing🙃🙃
Gonna keep trying to rerun it for a bit but…
yayuyokitano: Just had it happen in a different branch, its unrelated to changes made here, I guess something happened to something external
yayuyokitano: Well got good logs for the test flakiness, it looks vite related. Might talk to them a bit about it and see what they think. Merging this anyhow, test flakiness is unrelated to this PR

Patch:
diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json
index 03d8d006..a131dfb4 100644
--- a/src/_locales/en/messages.json
+++ b/src/_locales/en/messages.json
@@ -887,6 +887,10 @@
         "message": "Replace",
         "description": "Label of regex edit replace input"
     },
+    "infoDeleteLabel": {
+        "message": "Delete",
+        "description": "Label of regex edit delete button"
+    },
     "infoPreviewLabel": {
         "message": "Preview",
         "description": "Label of regex edit preview output"
@@ -907,6 +911,18 @@
         "message": "Album artist",
         "description": "Label of album artist input row"
     },
+    "infoUseRegex": {
+        "message": "Use regex",
+        "description": "Title of checkbox to toggle bulk edit flag"
+    },
+    "infoMatchCase": {
+        "message": "Match case",
+        "description": "Title of checkbox to toggle bulk edit flag"
+    },
+    "infoMatchWholeTag": {
+        "message": "Match entire tag",
+        "description": "Title of checkbox to toggle bulk edit flag"
+    },
     "infoViewArtistPage": {
         "message": "View artist page: $1",
         "description": "Placeholder of album input"
diff --git a/src/core/storage/regex-edits.model.ts b/src/core/storage/regex-edits.model.ts
index 9cd476d0..76e2433b 100644
--- a/src/core/storage/regex-edits.model.ts
+++ b/src/core/storage/regex-edits.model.ts
@@ -43,23 +43,14 @@ export default abstract class RegexEditsModel extends CustomStorage<K> {
 	 * @param search - Search to save
 	 * @param replace - Replace to save
 	 */
-	async saveRegexEdit(
-		search: RegexFields,
-		replace: RegexFields,
-	): Promise<void> {
+	async saveRegexEdit(newEdit: RegexEdit): Promise<void> {
 		const storageData = await this.getRegexEditStorage();
 		if (storageData === null) {
-			await this.saveRegexEditToStorage([{ search, replace }]);
+			await this.saveRegexEditToStorage([newEdit]);
 			return;
 		}
 
-		const newStorageData = [
-			...storageData,
-			{
-				search,
-				replace,
-			},
-		];
+		const newStorageData = [...storageData, newEdit];
 
 		await this.saveRegexEditToStorage(newStorageData);
 	}
diff --git a/src/theme/themes.scss b/src/theme/themes.scss
index da788679..cadd168e 100644
--- a/src/theme/themes.scss
+++ b/src/theme/themes.scss
@@ -21,6 +21,7 @@ $themes: (
 		indeterminate: #8e8e8e,
 		site-image: url('../img/main/site-light.jpg'),
 		invert-filter: invert(0%),
+		interactable-border-color: transparent,
 		neuomorphic-bottom: 10px 10px 30px #bbb,
 		neuomorphic-top: -10px -10px 30px #fff9f9,
 		neuomorphic-small-bottom: 5px 5px 15px #bbb,
@@ -49,6 +50,7 @@ $themes: (
 		indeterminate: #8e8e8e,
 		site-image: url('../img/main/site-dark.jpg'),
 		invert-filter: invert(100%),
+		interactable-border-color: transparent,
 		neuomorphic-bottom: 10px 10px 30px #111,
 		neuomorphic-top: -10px -10px 30px #333,
 		neuomorphic-small-bottom: 5px 5px 15px #111,
@@ -75,11 +77,12 @@ $themes: (
 		indeterminate: #8e8e8e,
 		site-image: black,
 		invert-filter: invert(100%),
-		neuomorphic-bottom: 10px 10px 30px #111,
-		neuomorphic-top: -10px -10px 30px #333,
-		neuomorphic-small-bottom: 5px 5px 15px #111,
-		neuomorphic-small-top: -5px -5px 15px #333,
-		interactable-border: 1px solid yellow,
+		interactable-border-color: yellow,
+		interactable-border: 1px solid var(--interactable-border-color),
+		neuomorphic-bottom: none,
+		neuomorphic-top: none,
+		neuomorphic-small-bottom: none,
+		neuomorphic-small-top: none,
 	),
 	high-contrast-light: (
 		always-light: black,
@@ -101,11 +104,12 @@ $themes: (
 		indeterminate: #8e8e8e,
 		site-image: seashell,
 		invert-filter: invert(0%),
-		neuomorphic-bottom: 10px 10px 30px #bbb,
-		neuomorphic-top: -10px -10px 30px #fff9f9,
-		neuomorphic-small-bottom: 5px 5px 15px #bbb,
-		neuomorphic-small-top: -5px -5px 15px #fff9f9,
-		interactable-border: 1px solid black,
+		interactable-border-color: black,
+		interactable-border: 1px solid var(--interactable-border-color),
+		neuomorphic-bottom: none,
+		neuomorphic-top: none,
+		neuomorphic-small-bottom: none,
+		neuomorphic-small-top: none,
 	),
 );
 
diff --git a/src/ui/components/_util.scss b/src/ui/components/_util.scss
new file mode 100644
index 00000000..76e1cdbd
--- /dev/null
+++ b/src/ui/components/_util.scss
@@ -0,0 +1,1 @@
+$mobile-supports: '(-webkit-touch-callout: none)';
diff --git a/src/ui/components/context-menu/context-menu.module.scss b/src/ui/components/context-menu/context-menu.module.scss
index 2aa9e1fe..8289cb21 100644
--- a/src/ui/components/context-menu/context-menu.module.scss
+++ b/src/ui/components/context-menu/context-menu.module.scss
@@ -1,3 +1,5 @@
+@use '../util';
+
 .contextMenu {
 	background-color: var(--last-fm-bg);
 	color: var(--text);
@@ -9,7 +11,7 @@
 		padding: 0.5em 0;
 		position: relative;
 
-		@supports (-webkit-touch-callout: none) {
+		@supports #{util.$mobile-supports} {
 			&.activeDialogButton {
 				background-color: var(--always-last-fm-hover);
 				color: var(--active-text);
@@ -60,7 +62,7 @@
 			margin: 0 auto;
 		}
 
-		@supports not (-webkit-touch-callout: none) {
+		@supports not (#{util.$mobile-supports}) {
 			&:hover {
 				background-color: var(--always-last-fm-hover);
 				color: var(--active-text);
diff --git a/src/ui/components/icons.tsx b/src/ui/components/icons.tsx
index 63703830..6e1815a2 100644
--- a/src/ui/components/icons.tsx
+++ b/src/ui/components/icons.tsx
@@ -1,10 +1,10 @@
-import { JSXElement, createMemo } from 'solid-js';
+import { JSXElement, Show, createMemo } from 'solid-js';
 import styles from './icons.module.scss';
 import popupStyles from '@/ui/popup/popup.module.scss';
 import componentStyles from '@/ui/options/components/components.module.scss';
 
 function createMaterialIcon(Path: () => JSXElement) {
-	const Component = (props: { class?: string }) => {
+	const Component = (props: { class?: string; title?: string }) => {
 		const className = createMemo(() =>
 			props.class
 				? `${styles.materialIcon} ${props.class}`
@@ -21,6 +21,9 @@ function createMaterialIcon(Path: () => JSXElement) {
 				{...props}
 				class={className()}
 			>
+				<Show when={props.title}>
+					<title>{props.title}</title>
+				</Show>
 				<Path />
 			</svg>
 		);
@@ -124,6 +127,10 @@ export const CancelScheduleSendOutlined = createMaterialIcon(() => (
 	</g>
 ));
 
+export const CaseSensitiveOutlined = createMaterialIcon(() => (
+	<path d="M20.06 18a3.99 3.99 0 0 1-.2-.89c-.67.7-1.48 1.05-2.41 1.05c-.83 0-1.52-.24-2.05-.71c-.53-.45-.8-1.06-.8-1.79c0-.88.33-1.56 1-2.05c.67-.49 1.61-.73 2.83-.73h1.4v-.64c0-.49-.15-.88-.45-1.17c-.3-.29-.75-.43-1.33-.43c-.52 0-.95.12-1.3.36c-.35.25-.52.54-.52.89h-1.46c0-.43.15-.84.45-1.24c.28-.4.71-.71 1.22-.94c.51-.21 1.06-.35 1.69-.35c.98 0 1.74.24 2.29.73s.84 1.16.86 2.02V16c0 .8.1 1.42.3 1.88V18zm-2.4-1.12c.45 0 .88-.11 1.29-.32c.4-.21.7-.49.88-.83v-1.57H18.7c-1.77 0-2.66.47-2.66 1.41c0 .43.15.73.46.96c.3.23.68.35 1.16.35m-12.2-3.17h4.07L7.5 8.29zM6.64 6h1.72l4.71 12h-1.93l-.97-2.57H4.82L3.86 18H1.93z" />
+));
+
 export const CheckOutlined = createMaterialIcon(() => (
 	<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
 ));
@@ -132,10 +139,6 @@ export const CloseOutlined = createMaterialIcon(() => (
 	<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
 ));
 
-export const CodeOutlined = createMaterialIcon(() => (
-	<path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z" />
-));
-
 export const ContactsOutlined = createMaterialIcon(() => (
 	<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V6h16v12zM4 0h16v2H4zm0 22h16v2H4zm8-10c1.38 0 2.5-1.12 2.5-2.5S13.38 7 12 7 9.5 8.12 9.5 9.5 10.62 12 12 12zm0-3.5c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5 7.49C17 13.9 13.69 13 12 13s-5 .9-5 2.99V17h10v-1.01zm-8.19-.49c.61-.52 2.03-1 3.19-1 1.17 0 2.59.48 3.2 1H8.81z" />
 ));
@@ -144,6 +147,10 @@ export const DeleteOutlined = createMaterialIcon(() => (
 	<path d="M16 9v10H8V9h8m-1.5-6h-5l-1 1H5v2h14V4h-3.5l-1-1zM18 7H6v12c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7z" />
 ));
 
+export const DeleteForeverOutlined = createMaterialIcon(() => (
+	<path d="M14.12 10.47L12 12.59l-2.13-2.12-1.41 1.41L10.59 14l-2.12 2.12 1.41 1.41L12 15.41l2.12 2.12 1.41-1.41L13.41 14l2.12-2.12zM15.5 4l-1-1h-5l-1 1H5v2h14V4zM6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM8 9h8v10H8V9z" />
+));
+
 export const DownloadOutlined = createMaterialIcon(() => (
 	<path d="M19 9h-4V3H9v6H5l7 7 7-7zm-8 2V5h2v6h1.17L12 13.17 9.83 11H11zm-6 7h14v2H5z" />
 ));
@@ -152,6 +159,10 @@ export const EditOutlined = createMaterialIcon(() => (
 	<path d="M14.06 9.02l.92.92L5.92 19H5v-.92l9.06-9.06M17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75z" />
 ));
 
+export const EditNoteOutlined = createMaterialIcon(() => (
+	<path d="M3 10h11v2H3zm0-2h11V6H3zm0 8h7v-2H3zm15.01-3.13.71-.71c.39-.39 1.02-.39 1.41 0l.71.71c.39.39.39 1.02 0 1.41l-.71.71zm-.71.71-5.3 5.3V21h2.12l5.3-5.3z" />
+));
+
 export const ErrorOutlined = createMaterialIcon(() => (
 	<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
 ));
@@ -273,6 +284,10 @@ export const PublishedWithChangesOutlined = createMaterialIcon(() => (
 	</>
 ));
 
+export const RegexOutlined = createMaterialIcon(() => (
+	<path d="M16 16.92c-.33.05-.66.08-1 .08c-.34 0-.67-.03-1-.08v-3.51l-2.5 2.48c-.5-.39-1-.89-1.39-1.39l2.48-2.5H9.08c-.05-.33-.08-.66-.08-1c0-.34.03-.67.08-1h3.51l-2.48-2.5c.19-.25.39-.5.65-.74c.24-.26.49-.46.74-.65L14 8.59V5.08c.33-.05.66-.08 1-.08c.34 0 .67.03 1 .08v3.51l2.5-2.48c.5.39 1 .89 1.39 1.39L17.41 10h3.51c.05.33.08.66.08 1c0 .34-.03.67-.08 1h-3.51l2.48 2.5c-.19.25-.39.5-.65.74c-.24.26-.49.46-.74.65L16 13.41zM5 19a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2" />
+));
+
 export const QueueMusicOutlined = createMaterialIcon(() => (
 	<g>
 		<path d="M22,6h-5v8.18C16.69,14.07,16.35,14,16,14c-1.66,0-3,1.34-3,3s1.34,3,3,3s3-1.34,3-3V8h3V6z M15,6H3v2h12V6z M15,10H3v2h12 V10z M11,14H3v2h8V14z" />
@@ -360,3 +375,7 @@ export const VolunteerActivismOutlined = createMaterialIcon(() => (
 		</g>
 	</g>
 ));
+
+export const WholeWordOutlined = createMaterialIcon(() => (
+	<path d="M11.14 4L6.43 16h1.93l.96-2.57h5.35l.97 2.57h1.93L12.86 4M12 6.29l2.03 5.42H9.96M20 14v4H4v-3H2v5h20v-6Z" />
+));
diff --git a/src/ui/options/components/components.module.scss b/src/ui/options/components/components.module.scss
index ff3c8f21..396b8208 100644
--- a/src/ui/options/components/components.module.scss
+++ b/src/ui/options/components/components.module.scss
@@ -545,14 +545,26 @@ summary {
 		'track tracksearch trackreplace' 2em
 		'artist artistsearch artistreplace' 2em
 		'album albumsearch albumreplace' 2em
-		'albumartist albumartistsearch albumartistreplace' 2em / auto 1fr 1fr;
-	margin: 0.75em 0;
+		'albumartist albumartistsearch albumartistreplace' 2em
+		'flags flags flags' 2em / auto 1fr 1fr;
+	margin-bottom: 1.5em;
+	margin-top: 0.75em;
 	padding: 0.25em 1em;
 	width: 100%;
 
+	.regexEditEntry {
+		border: var(--interactable-border);
+	}
+
 	/**
 	 * Set grid areas
 	 */
+	.regexDeleteFlags {
+		display: flex;
+		gap: 0.5em;
+		grid-area: flags;
+	}
+
 	.regexDeleteSearchLabel {
 		grid-area: search;
 	}
@@ -613,6 +625,7 @@ summary {
 .scrobble {
 	align-items: center;
 	background-color: var(--last-fm-bg-weak);
+	border: var(--interactable-border);
 	border-radius: 1em;
 	box-shadow: var(--neuomorphic-small-bottom), var(--neuomorphic-small-top);
 	display: flex;
@@ -667,13 +680,14 @@ summary {
 
 			.scrobbleActionButton {
 				background-color: var(--always-last-fm);
-				border: none;
+				border: var(--interactable-border);
 				border-radius: 3px;
 				color: var(--always-light);
 				cursor: pointer;
 
 				&:hover {
 					background-color: var(--always-last-fm-hover);
+					color: var(--active-text);
 				}
 
 				&:disabled {
@@ -686,7 +700,7 @@ summary {
 
 	&:has(:checked) {
 		background-color: var(--always-last-fm);
-		color: var(--always-light);
+		color: var(--active-text);
 
 		.scrobbleDetails .scrobbleActions .scrobbleActionButton {
 			background-color: var(--last-fm-bg-light);
@@ -703,7 +717,7 @@ summary {
 
 			.scrobbleDetails .scrobbleActions .scrobbleActionButton {
 				background-color: var(--always-last-fm);
-				color: var(--always-light);
+				color: var(--active-text);
 			}
 		}
 	}
diff --git a/src/ui/options/components/edit-options/regex-edits.tsx b/src/ui/options/components/edit-options/regex-edits.tsx
index 4fdf4fe7..f85652de 100644
--- a/src/ui/options/components/edit-options/regex-edits.tsx
+++ b/src/ui/options/components/edit-options/regex-edits.tsx
@@ -1,8 +1,13 @@
 import { t } from '@/util/i18n';
-import { For, Setter, createMemo, createResource } from 'solid-js';
+import { For, Setter, Show, createMemo, createResource } from 'solid-js';
 import * as BrowserStorage from '@/core/storage/browser-storage';
 import styles from '../components.module.scss';
-import { DeleteOutlined } from '@/ui/components/icons';
+import {
+	CaseSensitiveOutlined,
+	DeleteOutlined,
+	RegexOutlined,
+	WholeWordOutlined,
+} from '@/ui/components/icons';
 import { FieldType, RegexEdit, pascalCaseField } from '@/util/regex';
 import { ExportEdits, ImportEdits, ViewEdits } from './util';
 import { ModalType } from '../navigator';
@@ -108,11 +113,40 @@ function EditInfo(props: {
 				<Entry edit={props.edit} type={'artist'} />
 				<Entry edit={props.edit} type={'album'} />
 				<Entry edit={props.edit} type={'albumArtist'} />
+				<Flags edit={props.edit} />
 			</div>
 		</li>
 	);
 }
 
+/**
+ * Component that returns the grid entry for regex flags
+ */
+function Flags(props: { edit: RegexEdit }) {
+	return (
+		<div class={styles.regexDeleteFlags}>
+			<Show when={!props.edit.isRegexDisabled}>
+				<RegexOutlined
+					title={t('infoUseRegex')}
+					class={styles.regexEditEntry}
+				/>
+			</Show>
+			<Show when={!props.edit.isCaseInsensitive}>
+				<CaseSensitiveOutlined
+					title={t('infoMatchCase')}
+					class={styles.regexEditEntry}
+				/>
+			</Show>
+			<Show when={!props.edit.isGlobal}>
+				<WholeWordOutlined
+					title={t('infoMatchWholeTag')}
+					class={styles.regexEditEntry}
+				/>
+			</Show>
+		</div>
+	);
+}
+
 /**
  * Component that returns the grid entries for a single field of regex search/replace
  */
@@ -120,21 +154,23 @@ function Entry(props: { edit: RegexEdit; type: FieldType }) {
 	return (
 		<>
 			<span
-				class={styles[`regexDelete${pascalCaseField(props.type)}Label`]}
+				class={`${
+					styles[`regexDelete${pascalCaseField(props.type)}Label`]
+				} ${styles.regexEditEntry}`}
 			>
 				{t(`info${pascalCaseField(props.type)}Label`)}
 			</span>
 			<span
-				class={
+				class={`${
 					styles[`regexDelete${pascalCaseField(props.type)}Search`]
-				}
+				} ${styles.regexEditEntry}`}
 			>
 				{props.edit.search[props.type]}
 			</span>
 			<span
-				class={
+				class={`${
 					styles[`regexDelete${pascalCaseField(props.type)}Replace`]
-				}
+				} ${styles.regexEditEntry}`}
 			>
 				{props.edit.replace[props.type]}
 			</span>
diff --git a/src/ui/options/settings.module.scss b/src/ui/options/settings.module.scss
index 5c866ea8..c9d295d7 100644
--- a/src/ui/options/settings.module.scss
+++ b/src/ui/options/settings.module.scss
@@ -1,3 +1,5 @@
+@use '../components/util';
+
 body {
 	background-color: var(--background);
 	color: var(--text);
@@ -95,7 +97,7 @@ a {
 	position: relative;
 	width: 80vw;
 
-	@supports (-webkit-touch-callout: none) {
+	@supports #{util.$mobile-supports} {
 		border-radius: 0;
 		// stylelint-disable-next-line unit-no-unknown
 		height: 100dvh;
@@ -127,12 +129,13 @@ a {
 		right: 0;
 		top: 0;
 
-		@supports (-webkit-touch-callout: none) {
+		@supports #{util.$mobile-supports} {
 			padding: 0.75em 1em 0.5em;
 		}
 
 		&:hover {
 			background-color: var(--always-last-fm-hover);
+			color: var(--active-text);
 		}
 	}
 }
diff --git a/src/ui/popup/edit.tsx b/src/ui/popup/edit.tsx
index b2259282..0d4f3019 100644
--- a/src/ui/popup/edit.tsx
+++ b/src/ui/popup/edit.tsx
@@ -18,7 +18,7 @@ import browser from 'webextension-polyfill';
 import ClonedSong from '@/core/object/cloned-song';
 import {
 	CheckOutlined,
-	CodeOutlined,
+	EditNoteOutlined,
 	PublishedWithChangesOutlined,
 } from '@/ui/components/icons';
 import { sendBackgroundMessage } from '@/util/communication';
@@ -286,7 +286,7 @@ export default function Edit(props: { tab: Resource<ManagerTab> }) {
 											setIsRegex(true);
 										}}
 									>
-										<CodeOutlined />
+										<EditNoteOutlined />
 									</button>
 								</div>
 							</Show>
@@ -340,7 +340,7 @@ function EditContextMenu(props: {
 			},
 			{
 				namei18n: 'infoRegexTitleShort',
-				icon: CodeOutlined,
+				icon: EditNoteOutlined,
 				action: () => props.setIsRegex(true),
 			},
 		];
diff --git a/src/ui/popup/popup.module.scss b/src/ui/popup/popup.module.scss
index 535a3ab3..2e3cd888 100644
--- a/src/ui/popup/popup.module.scss
+++ b/src/ui/popup/popup.module.scss
@@ -1,3 +1,5 @@
+@use '../components/util';
+
 @mixin ios-text-input {
 	appearance: none;
 	background-color: var(--last-fm-bg);
@@ -223,16 +225,16 @@ a {
 		white-space: nowrap;
 	}
 
-	.searchWrapper {
+	.regexFieldWrapper {
 		align-self: center;
 		position: relative;
 
-		.searchField {
+		.regexFieldInput {
 			box-sizing: border-box;
 			width: 100%;
 		}
 
-		.regexTest {
+		.regexFieldItem {
 			position: absolute;
 			right: 0.1em;
 			top: 50%;
@@ -268,9 +270,83 @@ a {
 			width: 100%;
 		}
 	}
+
+	.regexFlagsWrapper {
+		align-items: center;
+		display: flex;
+		margin: 0.5em 0;
+
+		span {
+			font-size: 1.1em;
+			margin-right: 0.5em;
+		}
+	}
+
+	.regexToggleWrapper {
+		align-items: center;
+		border: 2px solid var(--interactable-border-color);
+		cursor: pointer;
+		display: flex;
+		padding: 0.25em;
+		width: max-content;
+
+		&:has(.regexToggleInput:checked) {
+			background-color: var(--last-fm);
+			color: var(--active-text);
+		}
+
+		/**
+		 * Check to make sure we are on desktop.
+		 * If we aren't, we don't want any highlighting,
+		 * as this will be confusing to the user.
+		 */
+		@supports not (#{util.$mobile-supports}) {
+			&:hover,
+			&:has(.regexToggleInput:focus-visible) {
+				background-color: var(--last-fm);
+				color: var(--active-text);
+			}
+
+			&:hover:has(.regexToggleInput:checked),
+			&:has(.regexToggleInput:focus-visible:checked) {
+				background-color: var(--background);
+				border: 2px solid var(--last-fm);
+				color: var(--text);
+			}
+		}
+
+		.regexToggleInput {
+			-webkit-appearance: none;
+			appearance: none;
+			border: none;
+			margin: 0;
+			padding: 0;
+			width: 0;
+		}
+	}
+
+	.button {
+		align-items: center;
+		background-color: var(--always-last-fm);
+		border: var(--interactable-border);
+		border-radius: 3px;
+		color: var(--always-light);
+		cursor: pointer;
+		display: flex;
+		font-size: 1em;
+		margin-right: -0.1em;
+		padding: 1px 7px 2px;
+		white-space: nowrap;
+		width: min-content;
+
+		&:hover {
+			background-color: var(--always-last-fm-hover);
+			color: var(--active-text);
+		}
+	}
 }
 
-@supports (-webkit-touch-callout: none) {
+@supports #{util.$mobile-supports} {
 	.root {
 		display: flex;
 		flex-direction: column-reverse;
@@ -386,15 +462,29 @@ a {
 			}
 		}
 
-		.searchWrapper {
+		.regexFieldWrapper {
 			align-items: center;
 			display: flex;
 			flex-direction: column;
+			padding: 0.25em 0.5em;
 			width: 100%;
 
-			.regexTest {
+			.regexFieldItem {
 				bottom: -0.3em; // I have no idea why this works - and I honestly don't want to know either
 				top: auto;
+
+				&.button {
+					bottom: -0.6em;
+				}
+			}
+		}
+
+		.regexFlagsWrapper {
+			flex-direction: column;
+
+			.regexToggleWrapper {
+				gap: 0.25em;
+				width: 100%;
 			}
 		}
 	}
diff --git a/src/ui/popup/regex.tsx b/src/ui/popup/regex.tsx
index ef5b96b7..099ebcb5 100644
--- a/src/ui/popup/regex.tsx
+++ b/src/ui/popup/regex.tsx
@@ -3,12 +3,17 @@ import styles from './popup.module.scss';
 import {
 	Album,
 	AlbumOff,
+	CaseSensitiveOutlined,
 	CheckOutlined,
 	CloseOutlined,
+	DeleteForeverOutlined,
+	DeleteOutlined,
 	MusicOff,
 	MusicNote,
 	PersonOff,
 	Person,
+	RegexOutlined,
+	WholeWordOutlined,
 } from '@/ui/components/icons';
 
 import { t } from '@/util/i18n';
@@ -25,13 +30,16 @@ import {
 	createSignal,
 	onMount,
 } from 'solid-js';
+import { createStore } from 'solid-js/store';
 import {
 	EditedFields,
 	FieldType,
 	RegexFields,
+	RegexFlags,
 	getProcessedFieldsNoRegex,
 	getSongFieldNoRegex,
 	pascalCaseField,
+	processRegexFlags,
 	replaceFields,
 	searchMatches,
 } from '@/util/regex';
@@ -47,24 +55,29 @@ import { isIos } from '../components/util';
 import BlockedTags from '@/core/storage/blocked-tags';
 import componentStyles from '@/ui/options/components/components.module.scss';
 
-const [searches, setSearches] = createSignal({
+const [searches, setSearches] = createStore<RegexFields>({
 	track: null,
 	artist: null,
 	album: null,
 	albumArtist: null,
-} as RegexFields);
-const [replaces, setReplaces] = createSignal({
+});
+const [replaces, setReplaces] = createStore<RegexFields>({
 	track: null,
 	artist: null,
 	album: null,
 	albumArtist: null,
-} as RegexFields);
-const [previews, setPreviews] = createSignal({
+});
+const [previews, setPreviews] = createStore<EditedFields>({
 	track: '',
 	artist: '',
 	album: '',
 	albumArtist: '',
-} as EditedFields);
+});
+const [flags, setFlags] = createStore<Record<keyof RegexFlags, boolean>>({
+	isRegexDisabled: true,
+	isCaseInsensitive: true,
+	isGlobal: false,
+});
 const blockedTags = new BlockedTags();
 
 /**
@@ -97,6 +110,9 @@ export default function Regex(props: {
 			}}
 			ref={setRegexContainer}
 		>
+			<Show when={isIos()}>
+				<Flags />
+			</Show>
 			<Show when={!isIos()}>
 				<div class={styles.entryWrapper}>
 					<span />
@@ -156,6 +172,36 @@ export default function Regex(props: {
 	);
 }
 
+function Flags() {
+	return (
+		<div
+			class={styles.regexFlagsWrapper}
+			role="group"
+			aria-label={t('optionsOptions')}
+		>
+			<span>{t('optionsOptions')}</span>
+			<RegexFlagCheckbox
+				i18nTitle="infoUseRegex"
+				icon={RegexOutlined}
+				isInverted={true}
+				flag="isRegexDisabled"
+			/>
+			<RegexFlagCheckbox
+				i18nTitle="infoMatchCase"
+				icon={CaseSensitiveOutlined}
+				isInverted={true}
+				flag="isCaseInsensitive"
+			/>
+			<RegexFlagCheckbox
+				i18nTitle="infoMatchWholeTag"
+				icon={WholeWordOutlined}
+				isInverted={true}
+				flag="isGlobal"
+			/>
+		</div>
+	);
+}
+
 /**
  * Label, inputs, and preview for a single song field.
  */
@@ -173,19 +219,18 @@ function Entry(props: {
 	setMaxCellWidth: Setter<number>;
 }) {
 	onMount(() => {
-		const type = props.type;
-		const clonedSong = props.clonedSong;
-		setPreviews((prev) => ({
-			...prev,
-			[type]: getSongFieldNoRegex(clonedSong, type),
-		}));
+		setPreviews({
+			[props.type]: getSongFieldNoRegex(props.clonedSong, props.type),
+		});
 	});
-
 	createEffect(() => {
 		setPreviews(() =>
 			replaceFields(
-				searches(),
-				replaces(),
+				{
+					search: searches,
+					replace: replaces,
+					...processRegexFlags(flags),
+				},
 				getProcessedFieldsNoRegex(props.clonedSong),
 			),
 		);
@@ -350,7 +395,7 @@ function SearchField(props: {
 	clonedSong: ClonedSong | undefined;
 }) {
 	return (
-		<div class={styles.searchWrapper}>
+		<div class={styles.regexFieldWrapper}>
 			<Show when={isIos()}>
 				<label
 					for={`${props.type}SearchInput`}
@@ -362,33 +407,33 @@ function SearchField(props: {
 			<input
 				id={`${props.type}SearchInput`}
 				type="text"
-				class={styles.searchField}
+				class={styles.regexFieldInput}
 				onInput={(e) =>
-					setSearches((prev) => ({
-						...prev,
+					setSearches({
 						[props.type]: e.currentTarget.value || null,
-					}))
+					})
 				}
 				title={t('infoSearchLabel')}
 			/>
 			<Switch
 				fallback={
 					<CloseOutlined
-						class={`${styles.regexTest} ${styles.regexFailure}`}
+						class={`${styles.regexFieldItem} ${styles.regexFailure}`}
 					/>
 				}
 			>
-				<Match when={!searches()[props.type]}>
+				<Match when={!searches[props.type]}>
 					<></>
 				</Match>
 				<Match
 					when={searchMatches(
-						searches()[props.type],
+						searches[props.type],
 						getSongFieldNoRegex(props.clonedSong, props.type),
+						processRegexFlags(flags),
 					)}
 				>
 					<CheckOutlined
-						class={`${styles.regexTest} ${styles.regexSuccess}`}
+						class={`${styles.regexFieldItem} ${styles.regexSuccess}`}
 					/>
 				</Match>
 			</Switch>
@@ -400,28 +445,53 @@ function SearchField(props: {
  * A single replace input for a single song property.
  */
 function ReplaceField(props: { type: FieldType }) {
+	const [shouldDelete, setShouldReplace] = createSignal(false);
+	const [curReplace, setCurReplace] = createSignal('');
+
+	createEffect(() => {
+		setReplaces({
+			[props.type]: shouldDelete() ? '' : curReplace() || null,
+		});
+	});
+
 	return (
-		<>
-			<Show when={isIos()}>
-				<label
-					for={`${props.type}ReplaceInput`}
-					class={styles.iosReplaceLabel}
-				>
-					{t('infoReplaceLabel')}
-				</label>
-			</Show>
-			<input
-				id={`${props.type}ReplaceInput`}
-				type="text"
-				onInput={(e) =>
-					setReplaces((prev) => ({
-						...prev,
-						[props.type]: e.currentTarget.value || null,
-					}))
+		<div class={styles.regexFieldWrapper}>
+			<Switch>
+				<Match when={shouldDelete()}>
+					<span>{t('infoDeleteLabel')}</span>
+				</Match>
+				<Match when={!shouldDelete()}>
+					<Show when={isIos()}>
+						<label
+							for={`${props.type}ReplaceInput`}
+							class={styles.iosReplaceLabel}
+						>
+							{t('infoReplaceLabel')}
+						</label>
+					</Show>
+					<input
+						id={`${props.type}ReplaceInput`}
+						type="text"
+						onInput={(e) => setCurReplace(e.currentTarget.value)}
+						class={styles.regexFieldInput}
+						title={t('infoReplaceLabel')}
+					/>
+				</Match>
+			</Switch>
+			<button
+				class={`${styles.regexFieldItem} ${styles.button}`}
+				title={
+					shouldDelete()
+						? t('infoReplaceLabel')
+						: t('infoDeleteLabel')
 				}
-				title={t('infoReplaceLabel')}
-			/>
-		</>
+				onClick={() => setShouldReplace((cur) => !cur)}
+			>
+				<Show when={shouldDelete()} fallback={<DeleteOutlined />}>
+					<DeleteForeverOutlined />
+				</Show>
+			</button>
+		</div>
 	);
 }
 
@@ -443,7 +513,7 @@ function PreviewOutput(props: { type: FieldType }) {
 				id={`${props.type}PreviewOutput`}
 				title={t('infoPreviewLabel')}
 			>
-				{previews()[props.type]}
+				{previews[props.type]}
 			</output>
 		</>
 	);
@@ -466,15 +536,14 @@ function Footer(props: {
 			<Show when={props.clonedSong?.flags.isCorrectedByUser}>
 				<span class={styles.editWarning}>{t('infoEditedWarning')}</span>
 			</Show>
-			<div class={styles.regexFooterButtons}>
-				<Show when={!isIos()}>
+			<Show when={!isIos()}>
+				<Flags />
+				<div class={styles.regexFooterButtons}>
 					<div class={styles.controlButtons}>
 						<button
 							class={styles.controlButton}
 							title={t('infoSubmitTitle')}
-							onClick={() => {
-								saveEdit(props.tab);
-							}}
+							onClick={() => void saveEdit(props.tab)}
 						>
 							<CheckOutlined />
 						</button>
@@ -513,8 +582,8 @@ function Footer(props: {
 							)}
 						</Show>
 					</div>
-				</Show>
-			</div>
+				</div>
+			</Show>
 		</div>
 	);
 }
@@ -595,9 +664,46 @@ export function RegexEditContextMenu(props: { tab: Resource<ManagerTab> }) {
  * @param data - the new song data to edit to
  */
 async function saveEdit(tab: Resource<ManagerTab>) {
-	await regexEdits.saveRegexEdit(searches(), replaces());
+	await regexEdits.saveRegexEdit({
+		search: searches,
+		replace: replaces,
+		...processRegexFlags(flags),
+	});
 	sendBackgroundMessage(tab()?.tabId ?? -1, {
 		type: 'reprocessSong',
 		payload: undefined,
 	});
 }
+
+/**
+ * Regex flag checkbox
+ */
+function RegexFlagCheckbox(props: {
+	i18nTitle: string;
+	icon: typeof RegexOutlined;
+	isInverted?: true;
+	flag: keyof RegexFlags;
+}) {
+	return (
+		<label title={t(props.i18nTitle)} class={styles.regexToggleWrapper}>
+			<input
+				type="checkbox"
+				checked={
+					props.isInverted ? !flags[props.flag] : flags[props.flag]
+				}
+				class={styles.regexToggleInput}
+				onChange={() =>
+					setFlags((f) => ({
+						[props.flag]: !f[props.flag],
+					}))
+				}
+			/>
+			<props.icon />
+			<Show when={isIos()}>
+				<span class={styles.regexToggleLabel}>
+					{t(props.i18nTitle)}
+				</span>
+			</Show>
+		</label>
+	);
+}
diff --git a/src/util/regex.ts b/src/util/regex.ts
index bc613e65..82a16d82 100644
--- a/src/util/regex.ts
+++ b/src/util/regex.ts
@@ -25,13 +25,90 @@ export type EditedFields = {
 	[field in FieldType]: string;
 };
 
+/**
+ * Regex flags.
+ * If a new regex flag is added, it must also be added to {@link isValidFlag}
+ */
+export interface RegexFlags {
+	isRegexDisabled?: true;
+	isCaseInsensitive?: true;
+	isGlobal?: true;
+}
+
+/**
+ * Checks is string is a valid regex flag.
+ * If a new regex flag is added to {@link RegexFlags}, it must be added here too.
+ *
+ * @param flag - flag to check if is valid
+ * @returns true if valid, coercing argument into a key of {@link RegexFlags}
+ */
+function isValidFlag(flag: string): flag is keyof RegexFlags {
+	for (const f of ['isRegexDisabled', 'isCaseInsensitive', 'isGlobal']) {
+		if (f === flag) {
+			return true;
+		}
+	}
+	return false;
+}
+
 /**
  * Regex edit object.
  */
-export type RegexEdit = {
+export interface RegexEdit extends RegexFlags {
 	search: RegexFields;
 	replace: RegexFields;
-};
+}
+
+/**
+ * Get flags of an edit.
+ *
+ * @param edit - edit to get flags of
+ * @returns flags for the edit supplied
+ */
+const getRegexEditFlags = (edit: RegexEdit): RegexFlags => ({
+	isGlobal: edit.isGlobal,
+	isCaseInsensitive: edit.isCaseInsensitive,
+	isRegexDisabled: edit.isRegexDisabled,
+});
+
+/**
+ * @param str - string to escape regex in
+ * @returns string with regex escaped
+ */
+const escapeRegex = (str: string) =>
+	str.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
+
+/**
+ * @param flags - flags to stringify
+ * @returns stringified regex flags
+ */
+function stringifyFlags(flags: RegexFlags): string {
+	let res = '';
+	if (flags.isGlobal) {
+		res += 'g';
+	}
+	if (flags.isCaseInsensitive) {
+		res += 'i';
+	}
+	return res;
+}
+
+/**
+ * Takes a boolean based regex flag state from UI,
+ * and turns it into a true regex flag object
+ */
+export function processRegexFlags(
+	flags: Record<keyof RegexFlags, boolean>,
+): RegexFlags {
+	const processedFlags: RegexFlags = {};
+	for (const [key, value] of Object.entries(flags)) {
+		if (!isValidFlag(key) || !value) {
+			continue;
+		}
+		processedFlags[key] = true;
+	}
+	return processedFlags;
+}
 
 /**
  * Checks if a regex edit has already been applied to any of the fields this edit would apply to.
@@ -66,10 +143,12 @@ export function shouldApplyEdit(edit: RegexEdit, song: Song): boolean {
 		return false;
 	}
 
+	const flags = getRegexEditFlags(edit);
+
 	for (const [_key, field] of Object.entries(edit.search)) {
 		const key = _key as FieldType;
 		const songField = getSongField(song, key);
-		if (!searchMatches(field, songField)) {
+		if (!searchMatches(field, songField, flags)) {
 			return false;
 		}
 	}
@@ -81,14 +160,26 @@ export function shouldApplyEdit(edit: RegexEdit, song: Song): boolean {
  *
  * @param search - Search regex to check
  * @param text - Text to check
+ * @param flags - Flags of the regex edit
  * @returns True if the edit should be applied; false otherwise
  */
-export function searchMatches(search: string | null, text: string): boolean {
+export function searchMatches(
+	search: string | null,
+	text: string,
+	flags: RegexFlags,
+): boolean {
 	if (search === null) {
 		return true;
 	}
+
+	const processedSearch = flags.isRegexDisabled
+		? escapeRegex(search)
+		: search;
 	try {
-		const regex = new RegExp(`^${search}$`);
+		const regex = new RegExp(
+			flags.isGlobal ? processedSearch : `^${processedSearch}$`,
+			stringifyFlags(flags),
+		);
 		return regex.test(text);
 	} catch (err) {
 		return false;
@@ -107,12 +198,20 @@ function replaceField(
 	search: string | null,
 	replace: string | null,
 	text: string,
+	flags: RegexFlags,
 ): string {
 	if (search === null || replace === null) {
 		return text;
 	}
+
+	const processedSearch = flags.isRegexDisabled
+		? escapeRegex(search)
+		: search;
 	try {
-		const regex = new RegExp(`^${search}$`);
+		const regex = new RegExp(
+			flags.isGlobal ? processedSearch : `^${processedSearch}$`,
+			stringifyFlags(flags),
+		);
 		return text.replace(regex, replace);
 	} catch (err) {
 		return text;
@@ -128,22 +227,24 @@ function replaceField(
  * @returns Edited fields
  */
 export function replaceFields(
-	search: RegexFields,
-	replace: RegexFields,
+	edit: RegexEdit,
 	fields: EditedFields,
 ): EditedFields {
+	const flags = getRegexEditFlags(edit);
+
 	for (const _field of Object.keys(fields)) {
 		const field = _field as FieldType;
-		if (!searchMatches(search[field], fields[field])) {
+		if (!searchMatches(edit.search[field], fields[field], flags)) {
 			return fields;
 		}
 	}
 	for (const _field of Object.keys(fields)) {
 		const field = _field as FieldType;
 		fields[field] = replaceField(
-			search[field],
-			replace[field],
+			edit.search[field],
+			edit.replace[field],
 			fields[field],
+			getRegexEditFlags(edit),
 		);
 	}
 	return fields;
@@ -189,15 +290,11 @@ export function getProcessedFieldsNoRegex(
  * @param song - Song to apply regex to
  */
 export function editSong(edit: RegexEdit, song: Song) {
-	const fields = replaceFields(
-		edit.search,
-		edit.replace,
-		getProcessedFields(song),
-	);
+	const fields = replaceFields(edit, getProcessedFields(song));
 
 	for (const [_key, field] of Object.entries(fields)) {
 		const key = _key as FieldType;
-		if (edit.search[key] && edit.replace[key]) {
+		if (edit.search[key] && typeof edit.replace[key] === 'string') {
 			song.flags.isRegexEditedByUser[key] = true;
 			song.processed[key] = field;
 		}


Test patch:
diff --git a/tests/background/regex-edits.test.ts b/tests/background/regex-edits.test.ts
index 51e609b7..bccbecbc 100644
--- a/tests/background/regex-edits.test.ts
+++ b/tests/background/regex-edits.test.ts
@@ -1,4 +1,5 @@
 import webextensionPolyfill from '#/mocks/webextension-polyfill';
+import fetchPolyfill from '#/mocks/fetch';
 import Pipeline from '@/core/object/pipeline/pipeline';
 import Song from '@/core/object/song';
 import { SavedEdit } from '@/core/storage/options';
@@ -14,6 +15,8 @@ import { getConnectorById } from '@/util/util-connector';
 import { randomBytes } from 'crypto';
 import { beforeEach, describe, expect, it } from 'vitest';
 
+// ensure polyfill is loaded;
+fetchPolyfill;
 const pipeline = new Pipeline();
 
 describe('Should edit Regex', () => {
@@ -24,11 +27,14 @@ describe('Should edit Regex', () => {
 	});
 
 	it('Should add edits to storage', async () => {
-		await regexEdits.saveRegexEdit(epRemover.search, epRemover.replace);
-		await regexEdits.saveRegexEdit(
-			singleRemover.search,
-			singleRemover.replace,
-		);
+		await regexEdits.saveRegexEdit({
+			search: epRemover.search,
+			replace: epRemover.replace,
+		});
+		await regexEdits.saveRegexEdit({
+			search: singleRemover.search,
+			replace: singleRemover.replace,
+		});
 		expect(await regexEdits.getData()).to.deep.equal([
 			epRemover,
 			singleRemover,
@@ -36,15 +42,18 @@ describe('Should edit Regex', () => {
 	});
 
 	it('Should delete edits from storage', async () => {
-		await regexEdits.saveRegexEdit(epRemover.search, epRemover.replace);
-		await regexEdits.saveRegexEdit(
-			singleRemover.search,
-			singleRemover.replace,
-		);
-		await regexEdits.saveRegexEdit(
-			fuminnikkiFixer.search,
-			fuminnikkiFixer.replace,
-		);
+		await regexEdits.saveRegexEdit({
+			search: epRemover.search,
+			replace: epRemover.replace,
+		});
+		await regexEdits.saveRegexEdit({
+			search: singleRemover.search,
+			replace: singleRemover.replace,
+		});
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixer.search,
+			replace: fuminnikkiFixer.replace,
+		});
 		await regexEdits.deleteRegexEdit(1);
 		expect(await regexEdits.getData()).to.deep.equal([
 			epRemover,
@@ -53,10 +62,10 @@ describe('Should edit Regex', () => {
 	});
 
 	it("Should apply edit to song's artist", async () => {
-		await regexEdits.saveRegexEdit(
-			fuminnikkiFixer.search,
-			fuminnikkiFixer.replace,
-		);
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixer.search,
+			replace: fuminnikkiFixer.replace,
+		});
 		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
 		await pipeline.process(song, youtubeConnector);
 		expect(getProcessedFields(song)).to.deep.equal(
@@ -65,32 +74,38 @@ describe('Should edit Regex', () => {
 	});
 
 	it("Should apply edit to song's album", async () => {
-		await regexEdits.saveRegexEdit(epRemover.search, epRemover.replace);
+		await regexEdits.saveRegexEdit({
+			search: epRemover.search,
+			replace: epRemover.replace,
+		});
 		const song = new Song(correctFuminnikkiSong, youtubeConnector);
 		await pipeline.process(song, youtubeConnector);
 		expect(getProcessedFields(song)).to.deep.equal(processedFuminnikkiSong);
 	});
 
 	it("Should apply edit to song's artist and album", async () => {
-		await regexEdits.saveRegexEdit(
-			fuminnikkiFixer.search,
-			fuminnikkiFixer.replace,
-		);
-		await regexEdits.saveRegexEdit(epRemover.search, epRemover.replace);
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixer.search,
+			replace: fuminnikkiFixer.replace,
+		});
+		await regexEdits.saveRegexEdit({
+			search: epRemover.search,
+			replace: epRemover.replace,
+		});
 		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
 		await pipeline.process(song, youtubeConnector);
 		expect(getProcessedFields(song)).to.deep.equal(processedFuminnikkiSong);
 	});
 
 	it("Should apply only the most recent edit to song's artist only once", async () => {
-		await regexEdits.saveRegexEdit(
-			artistNumberSuffixer(1).search,
-			artistNumberSuffixer(1).replace,
-		);
-		await regexEdits.saveRegexEdit(
-			artistNumberSuffixer(2).search,
-			artistNumberSuffixer(2).replace,
-		);
+		await regexEdits.saveRegexEdit({
+			search: artistNumberSuffixer(1).search,
+			replace: artistNumberSuffixer(1).replace,
+		});
+		await regexEdits.saveRegexEdit({
+			search: artistNumberSuffixer(2).search,
+			replace: artistNumberSuffixer(2).replace,
+		});
 
 		const song = new Song(correctFuminnikkiSong, youtubeConnector);
 		await pipeline.process(song, youtubeConnector);
@@ -101,10 +116,10 @@ describe('Should edit Regex', () => {
 	});
 
 	it('Should apply edit to track that does not exist', async () => {
-		await regexEdits.saveRegexEdit(
-			artistNumberSuffixer(1).search,
-			artistNumberSuffixer(1).replace,
-		);
+		await regexEdits.saveRegexEdit({
+			search: artistNumberSuffixer(1).search,
+			replace: artistNumberSuffixer(1).replace,
+		});
 
 		const artist = randomBytes(32).toString('hex');
 		const track = randomBytes(32).toString('hex');
@@ -117,11 +132,14 @@ describe('Should edit Regex', () => {
 	});
 
 	it('Should have non-regex edits saved for preview', async () => {
-		await regexEdits.saveRegexEdit(
-			fuminnikkiFixer.search,
-			fuminnikkiFixer.replace,
-		);
-		await regexEdits.saveRegexEdit(epRemover.search, epRemover.replace);
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixer.search,
+			replace: fuminnikkiFixer.replace,
+		});
+		await regexEdits.saveRegexEdit({
+			search: epRemover.search,
+			replace: epRemover.replace,
+		});
 
 		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
 		await pipeline.process(song, youtubeConnector);
@@ -135,10 +153,10 @@ describe('Should edit Regex', () => {
 	});
 
 	it('Should not apply regex edit if there is a song edit', async () => {
-		await regexEdits.saveRegexEdit(
-			artistNumberSuffixer(1).search,
-			artistNumberSuffixer(1).replace,
-		);
+		await regexEdits.saveRegexEdit({
+			search: artistNumberSuffixer(1).search,
+			replace: artistNumberSuffixer(1).replace,
+		});
 
 		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
 		savedEdits.saveSongInfo(song, processedFuminnikkiSong as SavedEdit);
@@ -148,6 +166,228 @@ describe('Should edit Regex', () => {
 	});
 });
 
+describe('Should handle bulk edit flags', () => {
+	beforeEach(() => {
+		webextensionPolyfill.reset();
+		webextensionPolyfill.setUser();
+		regexEdits.init();
+	});
+
+	it('Should not apply regex edit with wrong casing if edit is not case insensitive', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixerInsensitive.search,
+			replace: fuminnikkiFixerInsensitive.replace,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			wrongProcessedFuminnikkiSong,
+		);
+	});
+
+	it('Should apply regex edit with wrong casing if edit is case insensitive', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixerInsensitive.search,
+			replace: fuminnikkiFixerInsensitive.replace,
+			isCaseInsensitive: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			processedFuminnikkiSongWithEP,
+		);
+	});
+
+	it('Should not apply non-regex edit with wrong casing if edit is not case insensitive', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixerInsensitive.search,
+			replace: fuminnikkiFixerInsensitive.replace,
+			isRegexDisabled: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			wrongProcessedFuminnikkiSong,
+		);
+	});
+
+	it('Should apply non-regex edit with wrong casing if edit is case insensitive', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixerInsensitive.search,
+			replace: fuminnikkiFixerInsensitive.replace,
+			isCaseInsensitive: true,
+			isRegexDisabled: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			processedFuminnikkiSongWithEP,
+		);
+	});
+
+	it('Should apply global regex edit when it covers entire string', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixer.search,
+			replace: fuminnikkiFixer.replace,
+			isGlobal: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			processedFuminnikkiSongWithEP,
+		);
+	});
+
+	it('Should apply global non-regex edit when it covers entire string', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixer.search,
+			replace: fuminnikkiFixer.replace,
+			isGlobal: true,
+			isRegexDisabled: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			processedFuminnikkiSongWithEP,
+		);
+	});
+
+	it('Should not apply global-reliant regex edit when global is not enabled', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminGlobal.search,
+			replace: fuminGlobal.replace,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			wrongProcessedFuminnikkiSong,
+		);
+	});
+
+	it('Should not apply global-reliant non-regex edit when global is not enabled', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminGlobal.search,
+			replace: fuminGlobal.replace,
+			isRegexDisabled: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			wrongProcessedFuminnikkiSong,
+		);
+	});
+
+	it('Should apply global-reliant regex edit when global is enabled', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminGlobal.search,
+			replace: fuminGlobal.replace,
+			isGlobal: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			processedPartialFuminnikkiSong,
+		);
+	});
+
+	it('Should apply global-reliant non-regex edit when global is enabled', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminGlobal.search,
+			replace: fuminGlobal.replace,
+			isGlobal: true,
+			isRegexDisabled: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			processedPartialFuminnikkiSong,
+		);
+	});
+
+	it('Should not apply global regex edit with wrong casing if case insensitive is off', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminGlobalInsensitive.search,
+			replace: fuminGlobalInsensitive.replace,
+			isGlobal: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			wrongProcessedFuminnikkiSong,
+		);
+	});
+
+	it('Should not apply global non-regex edit with wrong casing if case insensitive is off', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminGlobalInsensitive.search,
+			replace: fuminGlobalInsensitive.replace,
+			isGlobal: true,
+			isRegexDisabled: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			wrongProcessedFuminnikkiSong,
+		);
+	});
+
+	it('Should apply global regex edit with wrong casing if case insensitive is on', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminGlobalInsensitive.search,
+			replace: fuminGlobalInsensitive.replace,
+			isGlobal: true,
+			isCaseInsensitive: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			processedPartialFuminnikkiSong,
+		);
+	});
+
+	it('Should apply global non-regex edit with wrong casing if case insensitive is on', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminGlobalInsensitive.search,
+			replace: fuminGlobalInsensitive.replace,
+			isGlobal: true,
+			isCaseInsensitive: true,
+			isRegexDisabled: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			processedPartialFuminnikkiSong,
+		);
+	});
+
+	it('Should not apply regex if regex is disabled', async () => {
+		await regexEdits.saveRegexEdit({
+			search: epRemover.search,
+			replace: epRemover.replace,
+			isRegexDisabled: true,
+		});
+		const song = new Song(correctFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			processedFuminnikkiSongWithEP,
+		);
+	});
+});
+
 const epRemover: RegexEdit = {
 	search: {
 		track: null,
@@ -193,6 +433,51 @@ const fuminnikkiFixer: RegexEdit = {
 	},
 };
 
+const fuminnikkiFixerInsensitive: RegexEdit = {
+	search: {
+		track: null,
+		artist: 'fUmInnIkkI',
+		album: null,
+		albumArtist: null,
+	},
+	replace: {
+		track: null,
+		artist: 'フミンニッキ',
+		album: null,
+		albumArtist: null,
+	},
+};
+
+const fuminGlobal: RegexEdit = {
+	search: {
+		track: null,
+		artist: 'Fumin',
+		album: null,
+		albumArtist: null,
+	},
+	replace: {
+		track: null,
+		artist: 'フミン',
+		album: null,
+		albumArtist: null,
+	},
+};
+
+const fuminGlobalInsensitive: RegexEdit = {
+	search: {
+		track: null,
+		artist: 'fUmIn',
+		album: null,
+		albumArtist: null,
+	},
+	replace: {
+		track: null,
+		artist: 'フミン',
+		album: null,
+		albumArtist: null,
+	},
+};
+
 const artistNumberSuffixer = (suffix: number): RegexEdit => ({
 	search: {
 		track: null,
@@ -230,4 +515,18 @@ const processedFuminnikkiSongWithEP: State = {
 	album: 'Re:start - EP',
 };
 
+const wrongProcessedFuminnikkiSong: State = {
+	track: 'Re:start',
+	artist: 'Fuminnikki',
+	album: '',
+	albumArtist: '',
+};
+
+const processedPartialFuminnikkiSong: State = {
+	track: 'Re:start',
+	artist: 'フミンnikki',
+	album: '',
+	albumArtist: '',
+};
+
 const youtubeConnector = getConnectorById('youtube')!;
diff --git a/tests/mocks/fetch.ts b/tests/mocks/fetch.ts
new file mode 100644
index 00000000..c09dcaa7
--- /dev/null
+++ b/tests/mocks/fetch.ts
@@ -0,0 +1,53 @@
+import { vi } from 'vitest';
+
+const createFetchResponse = (data: unknown) => ({
+	json: () => Promise.resolve(data),
+	ok: Boolean(data),
+});
+
+const responses = {
+	'https://musicbrainz.org/ws/2/release?fmt=json&query=title:+"Re:start"^3 Re:start artistname:+"フミンニッキ"^4フミンニッキ':
+		{
+			count: 14693,
+			releases: [{ id: '3e3d2a0f-3fce-4cdb-b6a1-b8c070ce9580' }],
+		},
+
+	'https://coverartarchive.org/release/3e3d2a0f-3fce-4cdb-b6a1-b8c070ce9580/front-500':
+		true,
+	'https://coverartarchive.org/release/674ccc28-5a18-4154-b0f7-e984420b0fc6/front-500':
+		false,
+	'https://musicbrainz.org/ws/2/release-group?fmt=json&query=title:+"Re:start"^3 Re:start artistname:+"フミンニッキ2"^4フミンニッキ2':
+		{
+			count: 10000,
+			releases: [{ id: '32e00a5f-7542-4be0-ad65-a18f8b3df58d' }],
+		},
+	'https://coverartarchive.org/release/32e00a5f-7542-4be0-ad65-a18f8b3df58d/front-500':
+		false,
+	'https://musicbrainz.org/ws/2/release?fmt=json&query=title:+"8a27256b6e2dfb99569bc035c7c0de159d36d6bb4e6a4b866f4bd90c95aebf0f"^3 8a27256b6e2dfb99569bc035c7c0de159d36d6bb4e6a4b866f4bd90c95aebf0f artistname:+"d21308b23b135a46b72024f1233f4942d8e294e11c1f4ba05f718bb80b50a44f1"^4d21308b23b135a46b72024f1233f4942d8e294e11c1f4ba05f718bb80b50a44f1':
+		{
+			count: 4127,
+			releases: [{ id: '84e4f664-5113-453e-a333-a4fba8022db8' }],
+		},
+	'https://coverartarchive.org/release/84e4f664-5113-453e-a333-a4fba8022db8/front-500':
+		true,
+	'https://musicbrainz.org/ws/2/release?fmt=json&query=title:+"Re:start"^3 Re:start artistname:+"Fuminnikki"^4Fuminnikki':
+		{
+			count: 14690,
+			releases: [{ id: '674ccc28-5a18-4154-b0f7-e984420b0fc6' }],
+		},
+};
+
+class FetchMocker {
+	constructor() {
+		(global.fetch as unknown) = vi.fn((input: string) => {
+			if (!(input in responses)) {
+				throw `${input} not mocked`;
+			}
+			return Promise.resolve(
+				createFetchResponse(responses[input as keyof typeof responses]),
+			);
+		});
+	}
+}
+
+export default new FetchMocker();
diff --git a/tests/setup/handle-rejections.ts b/tests/setup/handle-rejections.ts
new file mode 100644
index 00000000..e83a0f7c
--- /dev/null
+++ b/tests/setup/handle-rejections.ts
@@ -0,0 +1,6 @@
+// FAIL LOUDLY on unhandled promise rejections / errors
+process.on('unhandledRejection', (reason) => {
+	console.log(reason);
+	console.trace('FAILED TO HANDLE PROMISE REJECTION');
+	process.exit(1);
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index 977024a1..7fd70f82 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -11,5 +11,6 @@ export default {
 	plugins: [ConditionalCompile()],
 	test: {
 		testTimeout: 10000,
+		setupFiles: ['./tests/setup/handle-rejections.ts'],
 	},
 };