diff --git a/docs/guide/settings.md b/docs/guide/settings.md index 7ece6b0..da5f466 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -12,9 +12,6 @@ For example, below are some valid folder names: - `imported_notes/apple_books/highlights` - `3 - Resources/My Books/Apple Books/Unprocessed` -If the highlight folder is not empty and the [Backup highlights](#backup-highlights) setting is enabled, the plugin will save the existing highlights to a backup folder before importing new highlights. If the setting is disabled, the plugin will overwrite the contents of the highlight folder. - - ## Import highlights on start - Default value: Turned off @@ -24,13 +21,61 @@ Import all highlights from all your books when Obsidian starts. Respects the [Ba ## Backup highlights - Default value: Turned off -- Backup folder template: `<highlights-folder>-bk-<timestamp>`. For example, `ibooks-highlights-bk-1704060001`. +- Backup template: + - for the highlight folder: `<highlights-folder>-bk-<timestamp>`. For example, `ibooks-highlights-bk-1704060001`. + - for a specific book: `<highlights-file>-bk-<timestamp>`. For example, `Building a Second Brain-bk-1704060001`. + +Backup highlights before import. +- When importing all highlights, the [highlight folder](#highlight-folder) contents (see the note below) will be backed up. +- When importing highlights from a specific book, the specific highlights file will be backed up, if it exists. + +The backup name is pre-configured based on the template above and cannot be changed. + +::: details Examples + +**Import all highlights** + +Initial state +```plaintext +. +└── ibooks-highlights + ├── Atomic Habits - Tiny Changes, Remarkable Results + └── Building a Second Brain +``` +After import +```plaintext +. +├── ibooks-highlights +│ └── <newly imported highlights> +└── ibooks-highlights-bk-1723233525489 + ├── Atomic Habits - Tiny Changes, Remarkable Results + └── Building a Second Brain +``` +**Import highlights from a specific book** + +Initial state +```plaintext +. +└── ibooks-highlights + ├── Atomic Habits - Tiny Changes, Remarkable Results + └── Building a Second Brain +``` +After import +```plaintext +. +└── ibooks-highlights + ├── Atomic Habits - Tiny Changes, Remarkable Results + ├── Atomic Habits - Tiny Changes, Remarkable Results-bk-1723234215251 + └── Building a Second Brain +``` + +::: -Backup highlights folder before import. The backup folder name is pre-configured based on the template above and cannot be changed. The backup is created inside the [highlight folder](#highlight-folder). +> [!NOTE] +> The plugin will back up only the files that are direct children of the [highlight folder](#highlight-folder). If you (for some reason) have a nested folder structure inside the [highlight folder](#highlight-folder), these folders will not be backed up and will be overwritten on import. -> [!WARNING] -> If the setting is disabled, the plugin will overwrite the contents of the [highlight folder](#highlight-folder) on import. -> This behavior will be improved based on the feedback received: [Issue #34](https://github.com/bandantonio/obsidian-apple-books-highlights-plugin/issues/34#issuecomment-2231429171) +> [!TIP] +> To prevent accidental data loss when the setting is turned off, the plugin will display a confirmation dialog before overwriting the existing highlights. ## Highlights sorting criterion diff --git a/main.ts b/main.ts index 2a26795..1cd5117 100644 --- a/main.ts +++ b/main.ts @@ -1,5 +1,5 @@ import { Notice, Plugin } from 'obsidian'; -import { IBookHighlightsPluginSearchModal } from './src/search'; +import { IBookHighlightsPluginSearchModal, OverwriteBookModal } from './src/search'; import { aggregateBookAndHighlightDetails } from './src/methods/aggregateDetails'; import SaveHighlights from './src/methods/saveHighlightsToVault'; import { AppleBooksHighlightsImportPluginSettings, IBookHighlightsSettingTab } from './src/settings'; @@ -17,12 +17,18 @@ export default class IBookHighlightsPlugin extends Plugin { } this.addRibbonIcon('book-open', this.manifest.name, async () => { - await this.aggregateAndSaveHighlights().then(() => { - new Notice('Apple Books highlights imported successfully'); - }).catch((error) => { - new Notice(`[${this.manifest.name}]:\nError importing highlights. Check console for details (⌥ ⌘ I)`, 0); + try { + this.settings.backup + ? await this.aggregateAndSaveHighlights().then(() => { + new Notice('Apple Books highlights imported successfully'); + }).catch((error) => { + new Notice(`[${this.manifest.name}]:\nError importing highlights. Check console for details (⌥ ⌘ I)`, 0); + console.error(`[${this.manifest.name}]: ${error}`); + }) + : new OverwriteBookModal(this.app, this).open(); + } catch (error) { console.error(`[${this.manifest.name}]: ${error}`); - }); + } }); this.addSettingTab(new IBookHighlightsSettingTab(this.app, this)); @@ -32,7 +38,9 @@ export default class IBookHighlightsPlugin extends Plugin { name: 'Import all', callback: async () => { try { - await this.aggregateAndSaveHighlights(); + this.settings.backup + ? await this.aggregateAndSaveHighlights() + : new OverwriteBookModal(this.app, this).open(); } catch (error) { new Notice(`[${this.manifest.name}]:\nError importing highlights. Check console for details (⌥ ⌘ I)`, 0); console.error(`[${this.manifest.name}]: ${error}`); @@ -74,6 +82,6 @@ export default class IBookHighlightsPlugin extends Plugin { throw ('No highlights found. Make sure you made some highlights in your Apple Books.'); } - await this.saveHighlights.saveHighlightsToVault(highlights); + await this.saveHighlights.saveAllBooksHighlightsToVault(highlights); } } diff --git a/src/methods/saveHighlightsToVault.ts b/src/methods/saveHighlightsToVault.ts index b55ea31..dc31dd6 100644 --- a/src/methods/saveHighlightsToVault.ts +++ b/src/methods/saveHighlightsToVault.ts @@ -1,9 +1,10 @@ -import { App, Vault } from 'obsidian'; +import { App, TFile, Vault } from 'obsidian'; import path from 'path'; import { ICombinedBooksAndHighlights } from '../types'; import { AppleBooksHighlightsImportPluginSettings } from '../settings'; import { renderHighlightsTemplate } from './renderHighlightsTemplate'; import { sortHighlights } from 'src/methods/sortHighlights'; +import BackupHighlights from 'src/utils/backupHighlights'; export default class SaveHighlights { private app: App; @@ -16,35 +17,47 @@ export default class SaveHighlights { this.settings = settings; } - async saveHighlightsToVault(highlights: ICombinedBooksAndHighlights[]): Promise<void> { - const highlightsFolderPath = this.vault.getAbstractFileByPath( + async saveAllBooksHighlightsToVault(highlights: ICombinedBooksAndHighlights[]): Promise<void> { + const highlightsFolderPath = this.vault.getFolderByPath( this.settings.highlightsFolder ); const isBackupEnabled = this.settings.backup; - // // Backup highlights folder if backup is enabled if (highlightsFolderPath) { if (isBackupEnabled) { - const highlightsFilesToBackup = (await this.vault.adapter.list(highlightsFolderPath.path)).files; + const backupMethods = new BackupHighlights(this.vault, this.settings); + await backupMethods.backupAllHighlights(); + } else { + await this.vault.delete(highlightsFolderPath, true); + await this.vault.createFolder(this.settings.highlightsFolder); + } + } else { + await this.vault.createFolder(this.settings.highlightsFolder); + } - const highlightsBackupFolder = `${this.settings.highlightsFolder}-bk-${Date.now()}`; + for (const combinedHighlight of highlights) { + // Order highlights according to the value in settings + const sortedHighlights = sortHighlights(combinedHighlight, this.settings.highlightsSortingCriterion); - await this.vault.createFolder(highlightsBackupFolder); + // Save highlights to vault + const renderedTemplate = await renderHighlightsTemplate(sortedHighlights, this.settings.template); + const filePath = path.join(this.settings.highlightsFolder, `${combinedHighlight.bookTitle}.md`); - highlightsFilesToBackup.forEach(async (file: string) => { - const fileName = path.basename(file); + await this.createNewBookFile(filePath, renderedTemplate); + } + } - await this.vault.adapter.copy(file, path.join(highlightsBackupFolder, fileName)) - }); - } + async saveSingleBookHighlightsToVault(highlights: ICombinedBooksAndHighlights[], shouldCreateFile: boolean): Promise<void> { + const highlightsFolderPath = this.vault.getFolderByPath( + this.settings.highlightsFolder + ); - await this.vault.delete(highlightsFolderPath, true); + if (!highlightsFolderPath) { + await this.vault.createFolder(this.settings.highlightsFolder); } - await this.vault.createFolder(this.settings.highlightsFolder); - - highlights.forEach(async (combinedHighlight: ICombinedBooksAndHighlights) => { + for (const combinedHighlight of highlights) { // Order highlights according to the value in settings const sortedHighlights = sortHighlights(combinedHighlight, this.settings.highlightsSortingCriterion); @@ -52,10 +65,34 @@ export default class SaveHighlights { const renderedTemplate = await renderHighlightsTemplate(sortedHighlights, this.settings.template); const filePath = path.join(this.settings.highlightsFolder, `${combinedHighlight.bookTitle}.md`); - await this.vault.create( - filePath, - renderedTemplate - ); - }); + if (shouldCreateFile) { + await this.createNewBookFile(filePath, renderedTemplate); + } else { + const isBackupEnabled = this.settings.backup; + const backupMethods = new BackupHighlights(this.vault, this.settings); + + const vaultFile = this.vault.getFileByPath(filePath) as TFile; + + if (isBackupEnabled) { + backupMethods.backupSingleBookHighlights(combinedHighlight.bookTitle); + } + + await this.modifyExistingBookFile(vaultFile, renderedTemplate); + } + } + } + + async modifyExistingBookFile(file: TFile, data: string): Promise<void> { + await this.vault.modify( + file, + data + ); + } + + async createNewBookFile(filePath: string, data: string): Promise<void> { + await this.vault.create( + filePath, + data + ); } } diff --git a/src/search.ts b/src/search.ts index 1976265..7997e80 100644 --- a/src/search.ts +++ b/src/search.ts @@ -1,33 +1,37 @@ -import { App, Notice, SuggestModal } from 'obsidian'; +import { App, Modal, Notice, Setting, SuggestModal } from 'obsidian'; import IBookHighlightsPlugin from '../main'; import { ICombinedBooksAndHighlights } from './types'; import { aggregateBookAndHighlightDetails } from './methods/aggregateDetails'; +import { checkBookExistence } from './utils/checkBookExistence'; + abstract class IBookHighlightsPluginSuggestModal extends SuggestModal<ICombinedBooksAndHighlights> { plugin: IBookHighlightsPlugin; constructor( app: App, - plugin: IBookHighlightsPlugin) { + plugin: IBookHighlightsPlugin + ) { super(app); this.plugin = plugin; } } export class IBookHighlightsPluginSearchModal extends IBookHighlightsPluginSuggestModal { - async getSuggestions(query: string): Promise<ICombinedBooksAndHighlights[] > { - try { - const allBooks = await aggregateBookAndHighlightDetails(); - - return allBooks.filter(book => { - const titleMatch = book.bookTitle.toLowerCase().includes(query.toLowerCase()); - const authorMatch = book.bookAuthor.toLowerCase().includes(query.toLowerCase()); - - return titleMatch || authorMatch; - }); - } catch (error) { - new Notice(`[${this.plugin.manifest.name}]:\nError importing highlights. Check console for details (⌥ ⌘ I)`, 0); - console.error(`[${this.plugin.manifest.name}]: ${error}`); - return []; - } + async getSuggestions(query: string): Promise<ICombinedBooksAndHighlights[]> { + try { + const allBooks = await aggregateBookAndHighlightDetails(); + + return allBooks.filter(book => { + const titleMatch = book.bookTitle.toLowerCase().includes(query.toLowerCase()); + const authorMatch = book.bookAuthor.toLowerCase().includes(query.toLowerCase()); + + return titleMatch || authorMatch; + }); + } + catch (error) { + new Notice(`[${this.plugin.manifest.name}]:\nError importing highlights. Check console for details (⌥ ⌘ I)`, 0); + console.error(`[${this.plugin.manifest.name}]: ${error}`); + return []; + } } renderSuggestion(value: ICombinedBooksAndHighlights, el: HTMLElement) { @@ -36,7 +40,84 @@ export class IBookHighlightsPluginSearchModal extends IBookHighlightsPluginSugge } //eslint-disable-next-line - onChooseSuggestion(item: ICombinedBooksAndHighlights, event: MouseEvent | KeyboardEvent) { - this.plugin.saveHighlights.saveHighlightsToVault([item]); + async onChooseSuggestion(item: ICombinedBooksAndHighlights, event: MouseEvent | KeyboardEvent) { + const doesBookFileExist = checkBookExistence(item.bookTitle, this.app.vault, this.plugin.settings); + + const isBackupEnabled = this.plugin.settings.backup; + + if (!doesBookFileExist && !isBackupEnabled || + !doesBookFileExist && isBackupEnabled + ) { + this.plugin.saveHighlights.saveSingleBookHighlightsToVault([item], true); + + } else if (doesBookFileExist && !isBackupEnabled) { + new OverwriteBookModal(this.app, this.plugin, item).open(); + + } else if (doesBookFileExist && isBackupEnabled) { + this.plugin.saveHighlights.saveSingleBookHighlightsToVault([item], false); + } else { + this.plugin.saveHighlights.saveSingleBookHighlightsToVault([item], true); + } } } + +// This class is used to display a modal that asks for the user's consent +// to overwrite the existing book in the highlights folder +// It takes an optional `item` parameter with the selected book highlights +// When the parameter is not provided, the modal asks for the consent +// to overwrite all the books +export class OverwriteBookModal extends Modal { + plugin: IBookHighlightsPlugin; + item?: ICombinedBooksAndHighlights; + + constructor( + app: App, + plugin: IBookHighlightsPlugin, + item?: ICombinedBooksAndHighlights + ) { + super(app); + this.plugin = plugin; + this.item = item; + } + + onOpen() { + const { contentEl } = this; + const bookToOverwrite = this.item; + + if (bookToOverwrite) { + contentEl.createEl('p', { text: `The selected book already exists in your highlights folder:` }); + contentEl.createEl('p', { text: `${bookToOverwrite.bookTitle}`, cls: 'modal-rewrite-book-title'}); + contentEl.createEl('p', { text: 'Would you like to proceed with the overwrite?' }); + } else { + contentEl.createEl('span', { text: `Bulk import will overwrite` }); + contentEl.createEl('span', { text: ` ALL THE BOOKS `, cls: 'modal-rewrite-all-books' }); + contentEl.createEl('span', { text: `in your highlights folder` }); + contentEl.createEl('p', { text: 'Would you like to proceed with the overwrite?' }); + } + + new Setting(contentEl) + .addButton(YesButton => { + YesButton.setButtonText('Yes') + .setCta() + .onClick(() => { + bookToOverwrite + ? this.plugin.saveHighlights.saveSingleBookHighlightsToVault([bookToOverwrite], false) + : this.plugin.aggregateAndSaveHighlights(); + + this.close(); + }); + }) + + .addButton(NoButton => { + NoButton.setButtonText('No') + .onClick(() => { + this.close(); + }); + }); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} \ No newline at end of file diff --git a/src/settings.ts b/src/settings.ts index 3e51e5d..76223ba 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -56,7 +56,13 @@ export class IBookHighlightsSettingTab extends PluginSettingTab { new Setting(containerEl) .setName('Backup highlights') - .setDesc('Backup highlights folder before import. Backup folder template: <highlights-folder>-bk-<timestamp> (For example, ibooks-highlights-bk-1704060001)') + .setDesc(createFragment(el => { + el.appendText('Backup highlights before import.') + el.createEl('br') + el.appendText('- Folder template: <highlights-folder>-bk-<timestamp> (For example, ibooks-highlights-bk-1704060001).') + el.createEl('br') + el.appendText('- File template: <highlights-file>-bk-<timestamp> (For example, Building a Second Brain-bk-1704060001).') + })) .addToggle((toggle) => { toggle.setValue(this.plugin.settings.backup) .onChange(async (value) => { diff --git a/src/utils/backupHighlights.ts b/src/utils/backupHighlights.ts new file mode 100644 index 0000000..760f5cb --- /dev/null +++ b/src/utils/backupHighlights.ts @@ -0,0 +1,53 @@ +import { TFile, Vault } from 'obsidian'; +import path from 'path'; +import { AppleBooksHighlightsImportPluginSettings } from '../settings'; + +export default class BackupHighlights { + private vault: Vault; + private settings: AppleBooksHighlightsImportPluginSettings; + + constructor(vault: Vault, settings: AppleBooksHighlightsImportPluginSettings) { + this.vault = vault; + this.settings = settings; + } + + async backupAllHighlights(): Promise<void> { + const highlightsFolder = this.vault.getFolderByPath( + this.settings.highlightsFolder + ); + + if (highlightsFolder) { + // adapter.list returns only files that are direct children of the highlights folder. + // Only these files will be backed up. + const highlightsFilesToBackup = (await this.vault.adapter.list(highlightsFolder.path)).files; + + if (highlightsFilesToBackup.length > 0) { + const highlightsBackupFolder = `${this.settings.highlightsFolder}-bk-${Date.now()}`; + + await this.vault.createFolder(highlightsBackupFolder); + + // Instead of copying, it would be easier to move all the contents to the highlightsBackupFolder, + // but Obsidian API does not have a method to do it, + // so, the workaround is to copy those files. + for (const file of highlightsFilesToBackup) { + const fileName = path.basename(file); + await this.vault.adapter.copy(file, path.join(highlightsBackupFolder, fileName)); + } + + // Remove the highlights folder with all its contents + // and recreate it again for the subsequent import + await this.vault.delete(highlightsFolder, true); + await this.vault.createFolder(this.settings.highlightsFolder); + } + } + } + + async backupSingleBookHighlights(bookTitle: string): Promise<void> { + const bookFilePathToBackup = path.join(this.settings.highlightsFolder, `${bookTitle}.md`); + const vaultFile = this.vault.getFileByPath(bookFilePathToBackup) as TFile; + + const backupBookTitle = `${bookTitle}-bk-${Date.now()}.md`; + + await this.vault.adapter.copy(vaultFile.path, path.join(this.settings.highlightsFolder, backupBookTitle)); + } +} \ No newline at end of file diff --git a/src/utils/checkBookExistence.ts b/src/utils/checkBookExistence.ts new file mode 100644 index 0000000..c63b14f --- /dev/null +++ b/src/utils/checkBookExistence.ts @@ -0,0 +1,10 @@ +import { Vault } from 'obsidian'; +import path from 'path'; +import { AppleBooksHighlightsImportPluginSettings } from '../settings'; + +export const checkBookExistence = (bookTitle: string, vault: Vault, settings: AppleBooksHighlightsImportPluginSettings): boolean => { + const pathToBookFile = path.join(settings.highlightsFolder, `${bookTitle}.md`); + const doesBookFileExist = vault.getFileByPath(pathToBookFile); + + return doesBookFileExist ? true : false; +}; \ No newline at end of file diff --git a/styles.css b/styles.css index 118dda0..4d551d5 100644 --- a/styles.css +++ b/styles.css @@ -7,3 +7,12 @@ .ibooks-highlights-folder > .setting-item-control.setting-error > input { border-color: #b00; } + +.modal-rewrite-book-title { + text-align: center; + color: var(--interactive-accent); +} + +.modal-rewrite-all-books { + color: var(--interactive-accent); +} \ No newline at end of file diff --git a/test/backupHighlights.spec.ts b/test/backupHighlights.spec.ts new file mode 100644 index 0000000..3a9c3e8 --- /dev/null +++ b/test/backupHighlights.spec.ts @@ -0,0 +1,87 @@ +import BackupHighlights from '../src/utils/backupHighlights'; +import { AppleBooksHighlightsImportPluginSettings } from '../src/settings'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const mockVault = { + getFolderByPath: vi.fn(), + getFileByPath: vi.fn(), + createFolder: vi.fn().mockImplementation(async (folderName: string) => { + return; + }), + adapter: { + list: vi.fn(), + // eslint-disable-next-line + copy: vi.fn().mockImplementation(async (source: string, destination: string) => { + return; + }), + }, + delete: vi.fn().mockImplementation(async (folderPath: string, force: boolean) => { + return; + }), +}; + +const settings = new AppleBooksHighlightsImportPluginSettings(); + +beforeEach(() => { + Date.now = vi.fn().mockImplementation(() => 1704060001); +}); + +afterEach(() => { + vi.resetAllMocks(); +}); + +describe('Backup all highlights', () => { + test('Should skip backup if the highlights folder does not exist', async () => { + mockVault.getFolderByPath.mockReturnValue(null); + + const backupHighlights = new BackupHighlights(mockVault as any, settings); + await backupHighlights.backupAllHighlights(); + + expect(mockVault.getFolderByPath).toHaveBeenCalledWith(settings.highlightsFolder); + expect(mockVault.createFolder).not.toHaveBeenCalled(); + }); + + test('Should skip backup if the highlights folder is empty', async () => { + mockVault.getFolderByPath.mockReturnValue({ path: settings.highlightsFolder }); + mockVault.adapter.list.mockResolvedValue({ files: [] }); + + const backupHighlights = new BackupHighlights(mockVault as any, settings); + await backupHighlights.backupAllHighlights(); + + expect(mockVault.adapter.list).toHaveBeenCalledWith(settings.highlightsFolder); + expect(mockVault.createFolder).not.toHaveBeenCalled(); + expect(mockVault.delete).not.toHaveBeenCalled(); + }); + + test('Should backup all the content of the highlights folder', async () => { + const highlightsFolderPath = { path: settings.highlightsFolder }; + mockVault.getFolderByPath.mockReturnValue(highlightsFolderPath); + + const highlightsFiles = [`${settings.highlightsFolder}/file1.md`, `${settings.highlightsFolder}/file2.md`]; + mockVault.adapter.list.mockResolvedValue({ files: highlightsFiles }); + + const backupHighlights = new BackupHighlights(mockVault as any, settings); + await backupHighlights.backupAllHighlights(); + + expect(mockVault.createFolder).toHaveBeenCalledWith(`${settings.highlightsFolder}-bk-1704060001`); + expect(mockVault.adapter.copy).toHaveBeenCalledTimes(2); // highlightsFiles.length + + expect(mockVault.delete).toHaveBeenCalledWith(highlightsFolderPath, true); + expect(mockVault.createFolder).toHaveBeenCalledWith(settings.highlightsFolder); + }); +}); + +describe('Backup single book highlights', () => { + test('Should backup a single book highlights', async () => { + const bookTitle = 'Hello-world'; + const vaultFile = { path: `${settings.highlightsFolder}/${bookTitle}.md` }; + mockVault.getFileByPath = vi.fn().mockReturnValue(vaultFile); + + const backupBookTitle = `${bookTitle}-bk-1704060001.md`; + + const backupHighlights = new BackupHighlights(mockVault as any, settings); + await backupHighlights.backupSingleBookHighlights(bookTitle); + + expect(mockVault.adapter.copy).toHaveBeenCalledWith(vaultFile.path, `${settings.highlightsFolder}/${backupBookTitle}`); + }); +}); diff --git a/test/checkBookExistence.spec.ts b/test/checkBookExistence.spec.ts new file mode 100644 index 0000000..33702c9 --- /dev/null +++ b/test/checkBookExistence.spec.ts @@ -0,0 +1,33 @@ +import { checkBookExistence } from '../src/utils/checkBookExistence'; +import { AppleBooksHighlightsImportPluginSettings } from '../src/settings'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +describe('checkBookExistence', () => { + const mockVault = { + getFileByPath: vi.fn(), + }; + + const settings = new AppleBooksHighlightsImportPluginSettings(); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + test('Should return false if the book file does not exist', () => { + const bookTitle = 'Hello World'; + mockVault.getFileByPath.mockReturnValue(null); + + const checkResult = checkBookExistence(bookTitle, mockVault as any, settings); + + expect(checkResult).toBe(false); + }); + + test('Should return true if the book file exists', () => { + const bookTitle = 'Hello World'; + mockVault.getFileByPath.mockReturnValue({ path: `${settings.highlightsFolder}/${bookTitle}.md` }); + + const checkResult = checkBookExistence(bookTitle, mockVault as any, settings); + + expect(checkResult).toBe(true); + }); +}); diff --git a/test/saveHighlightsToVault.spec.ts b/test/saveHighlightsToVault.spec.ts index 9e0c8d1..154e2d8 100644 --- a/test/saveHighlightsToVault.spec.ts +++ b/test/saveHighlightsToVault.spec.ts @@ -4,6 +4,7 @@ import timezone from 'dayjs/plugin/timezone'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import SaveHighlights from '../src/methods/saveHighlightsToVault'; import { AppleBooksHighlightsImportPluginSettings } from '../src/settings'; +import defaultTemplate from '../src/template'; import { rawCustomTemplateMock, rawCustomTemplateMockWithWrappedTextBlockContainingNewlines } from './mocks/rawTemplates'; import { aggregatedUnsortedHighlights } from './mocks/aggregatedDetailsData'; import { @@ -14,7 +15,8 @@ import { import { ICombinedBooksAndHighlights } from '../src/types' const mockVault = { - getAbstractFileByPath: vi.fn(), + getFileByPath: vi.fn(), + getFolderByPath: vi.fn(), // eslint-disable-next-line createFolder: vi.fn().mockImplementation(async (folderName: string) => { return; @@ -23,6 +25,9 @@ const mockVault = { create: vi.fn().mockImplementation(async (filePath: string, data: string) => { return; }), + modify: vi.fn().mockImplementation(async (file: any, data: string) => { + return; + }), // eslint-disable-next-line delete: vi.fn().mockImplementation(async (folderPath: string, force: boolean) => { return; @@ -38,6 +43,7 @@ const mockVault = { beforeEach(() => { Date.now = vi.fn().mockImplementation(() => 1704060001); + settings.template = defaultTemplate; }); afterEach(() => { @@ -46,7 +52,7 @@ afterEach(() => { const settings = new AppleBooksHighlightsImportPluginSettings(); -describe('Save highlights to vault', () => { +describe('Save all highlights to vault', () => { dayjs.extend(utc); dayjs.extend(timezone); const tzSpy = vi.spyOn(dayjs.tz, 'guess'); @@ -54,12 +60,12 @@ describe('Save highlights to vault', () => { test('Should save highlights to vault using the default template', async () => { // eslint-disable-next-line const saveHighlights = new SaveHighlights({ vault: mockVault } as any, settings); - const spyGetAbstractFileByPath = vi.spyOn(mockVault, 'getAbstractFileByPath').mockReturnValue('ibooks-highlights'); + const spyGetFolderByPath = vi.spyOn(mockVault, 'getFolderByPath').mockReturnValue('ibooks-highlights'); - await saveHighlights.saveHighlightsToVault(aggregatedUnsortedHighlights as ICombinedBooksAndHighlights[]); + await saveHighlights.saveAllBooksHighlightsToVault(aggregatedUnsortedHighlights as ICombinedBooksAndHighlights[]); - expect(spyGetAbstractFileByPath).toHaveBeenCalledTimes(1); - expect(spyGetAbstractFileByPath).toHaveBeenCalledWith('ibooks-highlights'); + expect(spyGetFolderByPath).toHaveBeenCalledTimes(1); + expect(spyGetFolderByPath).toHaveBeenCalledWith('ibooks-highlights'); expect(mockVault.delete).toHaveBeenCalledTimes(1); expect(mockVault.delete).toHaveBeenCalledWith('ibooks-highlights', true); @@ -81,12 +87,12 @@ describe('Save highlights to vault', () => { // eslint-disable-next-line const saveHighlights = new SaveHighlights({ vault: mockVault } as any, settings); - const spyGetAbstractFileByPath = vi.spyOn(mockVault, 'getAbstractFileByPath').mockReturnValue('ibooks-highlights'); + const spyGetFolderByPath = vi.spyOn(mockVault, 'getFolderByPath').mockReturnValue('ibooks-highlights'); - await saveHighlights.saveHighlightsToVault(aggregatedUnsortedHighlights as ICombinedBooksAndHighlights[]); + await saveHighlights.saveAllBooksHighlightsToVault(aggregatedUnsortedHighlights as ICombinedBooksAndHighlights[]); - expect(spyGetAbstractFileByPath).toHaveBeenCalledTimes(1); - expect(spyGetAbstractFileByPath).toHaveBeenCalledWith('ibooks-highlights'); + expect(spyGetFolderByPath).toHaveBeenCalledTimes(1); + expect(spyGetFolderByPath).toHaveBeenCalledWith('ibooks-highlights'); expect(mockVault.delete).toHaveBeenCalledTimes(1); expect(mockVault.delete).toHaveBeenCalledWith('ibooks-highlights', true); @@ -105,12 +111,12 @@ describe('Save highlights to vault', () => { settings.template = rawCustomTemplateMockWithWrappedTextBlockContainingNewlines; // eslint-disable-next-line const saveHighlights = new SaveHighlights({ vault: mockVault } as any, settings); - const spyGetAbstractFileByPath = vi.spyOn(mockVault, 'getAbstractFileByPath').mockReturnValue('ibooks-highlights'); + const spyGetFolderByPath = vi.spyOn(mockVault, 'getFolderByPath').mockReturnValue('ibooks-highlights'); - await saveHighlights.saveHighlightsToVault(aggregatedUnsortedHighlights as ICombinedBooksAndHighlights[]); + await saveHighlights.saveAllBooksHighlightsToVault(aggregatedUnsortedHighlights as ICombinedBooksAndHighlights[]); - expect(spyGetAbstractFileByPath).toHaveBeenCalledTimes(1); - expect(spyGetAbstractFileByPath).toHaveBeenCalledWith('ibooks-highlights'); + expect(spyGetFolderByPath).toHaveBeenCalledTimes(1); + expect(spyGetFolderByPath).toHaveBeenCalledWith('ibooks-highlights'); expect(mockVault.delete).toHaveBeenCalledTimes(1); expect(mockVault.delete).toHaveBeenCalledWith('ibooks-highlights', true); @@ -128,12 +134,12 @@ describe('Save highlights to vault', () => { test('Should skip saving highlights to vault if highlights are not found', async () => { // eslint-disable-next-line const saveHighlights = new SaveHighlights({ vault: mockVault } as any, { ...settings, highlightsFolder: '' }); - const spyGetAbstractFileByPath = vi.spyOn(mockVault, 'getAbstractFileByPath').mockReturnValue(''); + const spyGetFolderByPath = vi.spyOn(mockVault, 'getFolderByPath').mockReturnValue(''); - await saveHighlights.saveHighlightsToVault(aggregatedUnsortedHighlights as ICombinedBooksAndHighlights[]); + await saveHighlights.saveAllBooksHighlightsToVault(aggregatedUnsortedHighlights as ICombinedBooksAndHighlights[]); - expect(spyGetAbstractFileByPath).toHaveBeenCalledTimes(1); - expect(spyGetAbstractFileByPath).toHaveBeenCalledWith(''); + expect(spyGetFolderByPath).toHaveBeenCalledTimes(1); + expect(spyGetFolderByPath).toHaveBeenCalledWith(''); expect(mockVault.delete).toHaveBeenCalledTimes(0); @@ -145,7 +151,7 @@ describe('Save highlights to vault', () => { // eslint-disable-next-line const saveHighlights = new SaveHighlights({ vault: mockVault } as any, { ...settings, backup: true }); - vi.spyOn(mockVault, 'getAbstractFileByPath').mockReturnValue('ibooks-highlights'); + vi.spyOn(mockVault, 'getFolderByPath').mockReturnValue('ibooks-highlights'); // eslint-disable-next-line const spyList = vi.spyOn(mockVault.adapter, 'list').mockImplementation(async (folderPath: string) => { return { @@ -156,7 +162,7 @@ describe('Save highlights to vault', () => { }; }); - await saveHighlights.saveHighlightsToVault(aggregatedUnsortedHighlights as ICombinedBooksAndHighlights[]); + await saveHighlights.saveAllBooksHighlightsToVault(aggregatedUnsortedHighlights as ICombinedBooksAndHighlights[]); expect(spyList).toHaveBeenCalledTimes(1); expect(spyList).toReturnWith({ @@ -174,3 +180,75 @@ describe('Save highlights to vault', () => { expect(mockVault.adapter.copy).toHaveBeenNthCalledWith(2, 'ibooks-highlights/Goodbye-world.md', 'ibooks-highlights-bk-1704060001/Goodbye-world.md'); }); }); + +describe('Save single book highlights to vault', () => { + test('Should save a single book when the book doesn\'t exist and backups are turned off', async () => { + const saveHighlights = new SaveHighlights({ vault: mockVault } as any, settings); + + await saveHighlights.saveSingleBookHighlightsToVault((aggregatedUnsortedHighlights as ICombinedBooksAndHighlights[]), true); + + expect(mockVault.createFolder).toHaveBeenCalledTimes(1); + expect(mockVault.createFolder).toHaveBeenCalledWith('ibooks-highlights'); + + expect(mockVault.create).toHaveBeenCalledTimes(1); + expect(mockVault.create).toHaveBeenCalledWith( + `ibooks-highlights/Apple iPhone - User Guide - Instructions - with - restricted - symbols - in - title.md`, + defaultTemplateMockWithAnnotationsSortedByDefault + ); + }); + + test('Should save a single book when the book doesn\'t exist and backups are turned on', async () => { + const saveHighlights = new SaveHighlights({ vault: mockVault } as any, { ...settings, backup: true }); + + await saveHighlights.saveSingleBookHighlightsToVault((aggregatedUnsortedHighlights as ICombinedBooksAndHighlights[]), true); + + expect(mockVault.createFolder).toHaveBeenCalledTimes(1); + expect(mockVault.createFolder).toHaveBeenCalledWith('ibooks-highlights'); + + expect(mockVault.create).toHaveBeenCalledTimes(1); + expect(mockVault.create).toHaveBeenCalledWith( + `ibooks-highlights/Apple iPhone - User Guide - Instructions - with - restricted - symbols - in - title.md`, + defaultTemplateMockWithAnnotationsSortedByDefault + ); + + expect(mockVault.adapter.copy).toHaveBeenCalledTimes(0); + expect(mockVault.delete).toHaveBeenCalledTimes(0); + }); + + test('Should modify a single book when it already exists in vault and backups are turned off', async () => { + const saveHighlights = new SaveHighlights({ vault: mockVault } as any, settings); + vi.spyOn(mockVault, 'getFileByPath').mockReturnValue({ + path: 'ibooks-highlights/Apple iPhone - User Guide - Instructions - with - restricted - symbols - in - title.md' + }); + + vi.spyOn(saveHighlights, 'modifyExistingBookFile'); + + await saveHighlights.saveSingleBookHighlightsToVault((aggregatedUnsortedHighlights as ICombinedBooksAndHighlights[]), false); + + expect(saveHighlights.modifyExistingBookFile).toHaveBeenCalledTimes(1); + expect(saveHighlights.modifyExistingBookFile).toHaveBeenCalledWith({ + path: 'ibooks-highlights/Apple iPhone - User Guide - Instructions - with - restricted - symbols - in - title.md' + }, + defaultTemplateMockWithAnnotationsSortedByDefault + ); + }); + + test('Should modify a single book when it already exists in vault and backups are turned on', async () => { + const saveHighlights = new SaveHighlights({ vault: mockVault } as any, { ...settings, backup: true }); + vi.spyOn(mockVault, 'getFolderByPath').mockReturnValue('ibooks-highlights'); + vi.spyOn(mockVault, 'getFileByPath').mockReturnValue({ + path: 'ibooks-highlights/Apple iPhone - User Guide - Instructions - with - restricted - symbols - in - title.md' + }); + + await saveHighlights.saveSingleBookHighlightsToVault((aggregatedUnsortedHighlights as ICombinedBooksAndHighlights[]), false); + + expect(mockVault.getFileByPath).toHaveBeenCalledTimes(2); + expect(mockVault.getFileByPath).toHaveBeenCalledWith('ibooks-highlights/Apple iPhone - User Guide - Instructions - with - restricted - symbols - in - title.md'); + + expect(mockVault.adapter.copy).toHaveBeenCalledTimes(1); + expect(mockVault.adapter.copy).toHaveBeenCalledWith( + 'ibooks-highlights/Apple iPhone - User Guide - Instructions - with - restricted - symbols - in - title.md', + 'ibooks-highlights/Apple iPhone - User Guide - Instructions - with - restricted - symbols - in - title-bk-1704060001.md' + ); + }); +}); diff --git a/test/search.spec.ts b/test/search.spec.ts new file mode 100644 index 0000000..80a7955 --- /dev/null +++ b/test/search.spec.ts @@ -0,0 +1,13 @@ +import { describe, test } from 'vitest'; + +// This functionality uses the Obsidian API which is hard to replicate in tests. +// Will be revisited later. +describe.todo('IBookHighlightsPluginSearchModal', () => { + test.todo('Should filter books based on query'); + test.todo('Should handle errors in getSuggestions'); +}); + +describe.todo('OverwriteBookModal', () => { + test.todo('Should show a modal window for bulk import'); + test.todo('Should show a modal window with book details'); +}); \ No newline at end of file diff --git a/vitest.config.mjs b/vitest.config.mjs index ce1a81a..d46c675 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -9,12 +9,12 @@ export default defineConfig({ enabled: true, provider: 'istanbul', reporter: ['lcov'], + include: [ + 'main.ts', + 'src/**/*.ts' + ], exclude: [ - 'src/template.ts', - 'drizzle.config.ts', - 'esbuild.config.mjs', - 'version-bump.mjs', - 'test/mocks/**/*', + 'src/search.ts', 'src/db/**/*', ] },