diff --git a/src/rendering/renderer/fileNameRenderer.spec.ts b/src/rendering/renderer/fileNameRenderer.spec.ts index 1aaa4c7..37a7acc 100644 --- a/src/rendering/renderer/fileNameRenderer.spec.ts +++ b/src/rendering/renderer/fileNameRenderer.spec.ts @@ -1,8 +1,24 @@ import type { Book, BookMetadata } from '~/models'; +import { settingsStore } from '~/store'; -import FileNameRenderer from './fileNameRenderer'; +import FileNameRenderer, { removeParenthesesFromText } from './fileNameRenderer'; describe('FileNameRenderer', () => { + beforeEach(() => { + settingsStore.store.set({ + amazonRegion: 'global', + highlightsFolder: '/', + lastSyncMode: 'amazon', + hasStartedSync: false, + isLoggedIn: false, + syncOnBoot: false, + downloadBookMetadata: true, + ignoredBooks: [], + removeParens: false, + removeParensWhitelist: '', + }); + }); + describe('validate', () => { it('should return true for valid template', () => { const renderer = new FileNameRenderer(''); @@ -205,5 +221,98 @@ describe('FileNameRenderer', () => { const result = renderer.render(book, metadata); expect(result).not.toContain('?'); }); + + it('removes bracket content from both title and author when enabled', () => { + settingsStore.actions.setRemoveParens(true); + + const book: Partial = { + title: 'Deep Work (Updated Edition)', + author: 'Cal Newport(Author)', + }; + const metadata: Partial = {}; + + const renderer = new FileNameRenderer('{{author}} - {{longTitle}}'); + expect(renderer.render(book, metadata)).toBe('Cal Newport - Deep Work.md'); + }); + + it('skips bracket cleanup for whitelisted book titles', () => { + settingsStore.actions.setRemoveParens(true); + settingsStore.actions.setRemoveParensWhitelist('Deep Work'); + + const book: Partial = { + title: 'Deep Work (Updated Edition)', + author: 'Cal Newport (Author)', + }; + const metadata: Partial = {}; + + const renderer = new FileNameRenderer('{{author}} - {{longTitle}}'); + expect(renderer.render(book, metadata)).toBe( + 'Cal Newport (Author) - Deep Work (Updated Edition).md' + ); + }); + + it('matches whitelist entries case-insensitively and skips title and author cleanup together', () => { + settingsStore.actions.setRemoveParens(true); + settingsStore.actions.setRemoveParensWhitelist('deep work'); + + const book: Partial = { + title: 'Deep Work (Updated Edition)', + author: 'Cal Newport (Author)', + }; + const metadata: Partial = {}; + + const renderer = new FileNameRenderer('{{author}} - {{longTitle}}'); + expect(renderer.render(book, metadata)).toBe( + 'Cal Newport (Author) - Deep Work (Updated Edition).md' + ); + }); + }); +}); + +describe('removeParenthesesFromText', () => { + it('removes English parentheses', () => { + expect(removeParenthesesFromText('Title (Subtitle)')).toBe('Title'); + }); + + it('removes Chinese parentheses', () => { + expect(removeParenthesesFromText('Title(Subtitle)')).toBe('Title'); + }); + + it('always cleans up spaces around removed English brackets', () => { + expect(removeParenthesesFromText('Title (Subtitle)')).toBe('Title'); + expect(removeParenthesesFromText('Title (Subtitle)')).toBe('Title'); + }); + + it('removes nested parentheses', () => { + // Nested English + expect(removeParenthesesFromText('Title (Subtitle (Extra))')).toBe('Title'); + + // Nested Chinese + expect(removeParenthesesFromText('Title(Subtitle(Extra))')).toBe('Title'); + + // Mixed nested + expect(removeParenthesesFromText('Title (Subtitle(Extra))')).toBe('Title'); + expect(removeParenthesesFromText('Title(Subtitle (Extra))')).toBe('Title'); + }); + + it('removes English and Chinese brackets together', () => { + expect(removeParenthesesFromText('Title (English)(Chinese)')).toBe('Title'); + }); + + it('handles edge cases', () => { + // Empty string result -> returns original + expect(removeParenthesesFromText('(Parens Only)')).toBe('(Parens Only)'); + expect(removeParenthesesFromText('(Parens Only)')).toBe('(Parens Only)'); + + // No parentheses + expect(removeParenthesesFromText('Title')).toBe('Title'); + + // Multiple separate parentheses + expect(removeParenthesesFromText('Title (One) (Two)')).toBe('Title'); + }); + + it('returns original text when removal would produce an empty string', () => { + expect(removeParenthesesFromText('()')).toBe('()'); + expect(removeParenthesesFromText('( )')).toBe('( )'); }); }); diff --git a/src/rendering/renderer/fileNameRenderer.ts b/src/rendering/renderer/fileNameRenderer.ts index cf95710..5fc78e4 100644 --- a/src/rendering/renderer/fileNameRenderer.ts +++ b/src/rendering/renderer/fileNameRenderer.ts @@ -1,7 +1,9 @@ import nunjucks, { Environment } from 'nunjucks'; import sanitize from 'sanitize-filename'; +import { get } from 'svelte/store'; import type { Book, BookMetadata } from '~/models'; +import { settingsStore } from '~/store'; import { fileNameTemplateVariables } from './templateVariables'; @@ -48,7 +50,27 @@ export default class FileNameRenderer { } public render(book: Partial, metadata: Partial): string { - const templateVariables = fileNameTemplateVariables(book, metadata); + const settings = get(settingsStore); + + // Apply bracket removal to filename-relevant book fields if enabled. + const processedBook = { ...book }; + if (settings.removeParens) { + const whitelist = settings.removeParensWhitelist + .split('\n') + .map((line: string) => line.trim().toLowerCase()) + .filter((line: string) => line !== ''); + + // Check whitelist against book title once — skip all processing if matched + const titleLower = (processedBook.title ?? '').toLowerCase(); + const isWhitelisted = whitelist.some((keyword) => titleLower.includes(keyword)); + + if (!isWhitelisted) { + processedBook.title = removeParenthesesFromText(processedBook.title ?? ''); + processedBook.author = removeParenthesesFromText(processedBook.author ?? ''); + } + } + + const templateVariables = fileNameTemplateVariables(processedBook, metadata); const rendered = this.nunjucks.renderString(this.template, templateVariables); @@ -57,3 +79,29 @@ export default class FileNameRenderer { return `${fileName}.md`; } } + +/** + * Remove English and Chinese bracketed content from text. + * Handles nested brackets by running multiple passes. + * Returns original text if removal would result in an empty string. + */ +export const removeParenthesesFromText = (text: string): string => { + let result = text; + + // Loop to handle nested brackets + let prev = ''; + while (prev !== result) { + prev = result; + + result = result.replace(/([^()]*)/g, ''); + + // Remove English brackets and surrounding spaces, avoiding double spaces. + result = result.replace(/\s*\([^()]*\)\s*/g, ' '); + } + + // Collapse multiple spaces and trim + result = result.replace(/ {2,}/g, ' ').trim(); + + // Return original text if removal resulted in empty string + return result || text; +}; diff --git a/src/settings/index.ts b/src/settings/index.ts index 66e882c..1d4f9bc 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -38,6 +38,7 @@ export class SettingsTab extends PluginSettingTab { this.amazonRegion(); this.downloadBookMetadata(); this.syncOnBoot(); + this.removeParentheses(); this.ignoredBooks(); this.sponsorMe(); } @@ -184,6 +185,38 @@ export class SettingsTab extends PluginSettingTab { }); } + private removeParentheses(): void { + new Setting(this.containerEl) + .setName('Remove bracket content from filename metadata') + .setDesc( + 'Automatically remove bracketed content from book titles and authors before rendering file names' + ) + .addToggle((toggle) => + toggle.setValue(get(settingsStore).removeParens).onChange((value) => { + settingsStore.actions.setRemoveParens(value); + this.display(); // Re-render to show/hide sub-settings + }) + ); + + if (get(settingsStore).removeParens) { + new Setting(this.containerEl) + .setName('Bracket cleanup whitelist') + .setDesc( + 'Books with titles containing any of these keywords will skip bracket cleanup for both title and author. One keyword per line, case-insensitive.' + ) + .addTextArea((textArea) => { + textArea + .setPlaceholder('e.g.\nWords of Radiance\nThe Pragmatic Programmer') + .setValue(get(settingsStore).removeParensWhitelist) + .onChange((value) => { + settingsStore.actions.setRemoveParensWhitelist(value); + }); + textArea.inputEl.rows = 4; + textArea.inputEl.cols = 50; + }); + } + } + private sponsorMe(): void { new Setting(this.containerEl) .setName('Sponsor') diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts index cfe5d1f..03d3a52 100644 --- a/src/store/settingsStore.ts +++ b/src/store/settingsStore.ts @@ -17,6 +17,8 @@ type Settings = { syncOnBoot: boolean; downloadBookMetadata: boolean; ignoredBooks: string[]; + removeParens: boolean; + removeParensWhitelist: string; // Deprecated - delete eventually noteTemplate?: string; @@ -32,6 +34,8 @@ const DEFAULT_SETTINGS: Settings = { syncOnBoot: false, downloadBookMetadata: true, ignoredBooks: [], + removeParens: false, + removeParensWhitelist: '', }; const createSettingsStore = () => { @@ -154,6 +158,20 @@ const createSettingsStore = () => { }); }; + const setRemoveParens = (value: boolean) => { + store.update((state) => { + state.removeParens = value; + return state; + }); + }; + + const setRemoveParensWhitelist = (value: string) => { + store.update((state) => { + state.removeParensWhitelist = value; + return state; + }); + }; + return { store, subscribe: store.subscribe, @@ -169,6 +187,8 @@ const createSettingsStore = () => { setDownloadBookMetadata, setAmazonRegion, setIgnoredBooks, + setRemoveParens, + setRemoveParensWhitelist, }, }; }; diff --git a/styles.css b/styles.css index 263e22e..879361a 100644 --- a/styles.css +++ b/styles.css @@ -20,6 +20,7 @@ 0 0 8px color-mix(in srgb, var(--interactive-accent) 55%, transparent) ); } + 50% { opacity: 0.65; box-shadow: 0 0 0 6px color-mix(in srgb, var(--interactive-accent) 0%, transparent);