Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 110 additions & 1 deletion src/rendering/renderer/fileNameRenderer.spec.ts
Original file line number Diff line number Diff line change
@@ -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('');
Expand Down Expand Up @@ -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<Book> = {
title: 'Deep Work (Updated Edition)',
author: 'Cal Newport(Author)',
};
const metadata: Partial<BookMetadata> = {};

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<Book> = {
title: 'Deep Work (Updated Edition)',
author: 'Cal Newport (Author)',
};
const metadata: Partial<BookMetadata> = {};

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<Book> = {
title: 'Deep Work (Updated Edition)',
author: 'Cal Newport (Author)',
};
const metadata: Partial<BookMetadata> = {};

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('( )');
});
});
50 changes: 49 additions & 1 deletion src/rendering/renderer/fileNameRenderer.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -48,7 +50,27 @@ export default class FileNameRenderer {
}

public render(book: Partial<Book>, metadata: Partial<BookMetadata>): 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);

Expand All @@ -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;
};
33 changes: 33 additions & 0 deletions src/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export class SettingsTab extends PluginSettingTab {
this.amazonRegion();
this.downloadBookMetadata();
this.syncOnBoot();
this.removeParentheses();
this.ignoredBooks();
this.sponsorMe();
}
Expand Down Expand Up @@ -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')
Expand Down
20 changes: 20 additions & 0 deletions src/store/settingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ type Settings = {
syncOnBoot: boolean;
downloadBookMetadata: boolean;
ignoredBooks: string[];
removeParens: boolean;
removeParensWhitelist: string;

// Deprecated - delete eventually
noteTemplate?: string;
Expand All @@ -32,6 +34,8 @@ const DEFAULT_SETTINGS: Settings = {
syncOnBoot: false,
downloadBookMetadata: true,
ignoredBooks: [],
removeParens: false,
removeParensWhitelist: '',
};

const createSettingsStore = () => {
Expand Down Expand Up @@ -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,
Expand All @@ -169,6 +187,8 @@ const createSettingsStore = () => {
setDownloadBookMetadata,
setAmazonRegion,
setIgnoredBooks,
setRemoveParens,
setRemoveParensWhitelist,
},
};
};
Expand Down
1 change: 1 addition & 0 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading