Skip to content

Commit

Permalink
feat: refactor book import logic to be more consistent and user-friendly
Browse files Browse the repository at this point in the history
This update includes:
- Refactor of single book import logic that does not overwrite existing highlights
- Dialog box to confirm overwrite of existing book highlight(s) for cases when backups are disabled
- Documentation describing the updated behavior
- Updated tests
  • Loading branch information
bandantonio committed Aug 9, 2024
1 parent 5ac0b62 commit dd6ff42
Show file tree
Hide file tree
Showing 12 changed files with 479 additions and 77 deletions.
61 changes: 53 additions & 8 deletions docs/guide/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
24 changes: 16 additions & 8 deletions main.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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));
Expand All @@ -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}`);
Expand Down Expand Up @@ -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);
}
}
79 changes: 58 additions & 21 deletions src/methods/saveHighlightsToVault.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,46 +17,82 @@ 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);

// Save highlights to vault
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
);
}
}
119 changes: 100 additions & 19 deletions src/search.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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();
}
}
8 changes: 7 additions & 1 deletion src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading

0 comments on commit dd6ff42

Please sign in to comment.