Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Switch to spawn (instead of exec). Add basic tests. #20

Merged
merged 9 commits into from
Jun 28, 2024
2 changes: 1 addition & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
node_modules/

test/mocks/
main.js
30 changes: 26 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on: [push]
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true

jobs:
node-setup:
name: ⚙️ Setup Node.js
Expand Down Expand Up @@ -33,17 +33,39 @@ jobs:
id: linter
run: npm run lint

run-tests:
name: 🧪 Tests
needs: node-setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/node-cache
id: cache-node-modules
with:
node-version: '20'

- name: 🧪 Run tests
id: tests
run: npm run coverage

- name: 📈 Send test coverage to Coveralls
id: coverage
if: steps.tests.outcome == 'success'
uses: coverallsapp/github-action@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

release-plugin:
name: 📦 Release Obsidian Plugin
needs: run-lint
needs: [run-lint, run-tests]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/node-cache
id: cache-node-modules
with:
node-version: '20'

- name: 📗 Build plugin
id: build_plugin
if: steps.cache-node-modules.outcome == 'success'
Expand All @@ -58,4 +80,4 @@ jobs:
files: |
main.js
manifest.json
styles.css
styles.css
47 changes: 25 additions & 22 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
# vscode
.vscode

# Intellij
*.iml
.idea

# npm
node_modules

# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js

# Exclude sourcemaps
*.map

# obsidian
data.json

# Exclude macOS Finder (System Explorer) View States
.DS_Store
# vscode
.vscode

# Intellij
*.iml
.idea

# npm
node_modules

# tests
coverage
test/mocks/testDatabase.sqlite
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js

# Exclude sourcemaps
*.map

# obsidian
data.json

# Exclude macOS Finder (System Explorer) View States
.DS_Store
2 changes: 2 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
npm run lint
npm test -- --no-watch
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Import all your Apple Books highlights to Obsidian.
![Plugin banner](plugin-banner.png)

![GitHub Downloads](https://img.shields.io/github/downloads/bandantonio/obsidian-apple-books-highlights-plugin/total?style=for-the-badge&logo=github&color=573e7a)
[![Coveralls](https://img.shields.io/coverallsCoverage/github/bandantonio/obsidian-apple-books-highlights-plugin?branch=master&style=for-the-badge&label=Test%20coverage)](https://coveralls.io/github/bandantonio/obsidian-apple-books-highlights-plugin?branch=master)

## Overview

Expand Down Expand Up @@ -138,4 +139,4 @@ Number of annotations:: {{annotations.length}}

Your feedback and ideas are more than welcome and highly appreciated! Join the discussion in the [Obsidian Forum](https://forum.obsidian.md/t/new-plugin-apple-books-import-highlights/76856).

If you have any question, feedback, or issue, feel free to open an issue.
If you have any question, feedback, or issue, feel free to open an issue.
11 changes: 11 additions & 0 deletions drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const MIGRATIONS_FOLDER_PATH = './migrations';
export const TEST_DATABASE_PATH = './test/mocks/testDatabase.sqlite';

export default {
driver: 'better-sqlite',
dbCredentials: {
url: TEST_DATABASE_PATH
},
schema: './src/db/schemas.ts',
out: MIGRATIONS_FOLDER_PATH,
};
2 changes: 1 addition & 1 deletion esbuild.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,4 @@ if (prod) {
process.exit(0);
} else {
await context.watch();
}
}
192 changes: 28 additions & 164 deletions main.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,27 @@
import * as child_process from 'child_process';
import * as Handlebars from 'handlebars';
import { normalizePath, Notice, Plugin } from 'obsidian';
import dayjs from 'dayjs';
import * as path from 'path';
import { promisify } from 'util';
import { DEFAULT_SETTINGS, IBookHighlightsSettingTab } from './src/settings';
import { Notice, Plugin } from 'obsidian';
import { IBookHighlightsPluginSearchModal } from './src/search';
import {
CombinedHighlight,
IBook,
IBookAnnotation,
IBookHighlightsPluginSettings
} from './src/types';
import { aggregateBookAndHighlightDetails } from './src/methods/aggregateDetails';
import SaveHighlights from './src/methods/saveHighlightsToVault';
import { AppleBooksHighlightsImportPluginSettings, IBookHighlightsSettingTab } from './src/settings';

export default class IBookHighlightsPlugin extends Plugin {
settings: IBookHighlightsPluginSettings;
settings: AppleBooksHighlightsImportPluginSettings;
saveHighlights: SaveHighlights;

async onload() {
const settings = await this.loadSettings();

this.saveHighlights = new SaveHighlights(this.app, settings);

if (settings.importOnStart) {
await this.importAndSaveHighlights();
await this.aggregateAndSaveHighlights();
}

this.addRibbonIcon('book-open', this.manifest.name, async () => {
await this.importAndSaveHighlights().then(() => {
await this.aggregateAndSaveHighlights().then(() => {
new Notice('Apple Books highlights imported successfully');
}).catch(() => {
}).catch((error) => {
new Notice(`[${this.manifest.name}]:\nError importing highlights. Check console for details (⌥ ⌘ I)`, 0);
console.error(`[${this.manifest.name}]: ${error}`);
});
});

Expand All @@ -38,18 +32,24 @@ export default class IBookHighlightsPlugin extends Plugin {
name: 'Import all',
callback: async () => {
try {
await this.importAndSaveHighlights();
await this.aggregateAndSaveHighlights();
} catch (error) {
new Notice(`[${this.manifest.name}]:\nError importing highlights. Check console for details (⌥ ⌘ I)`, 0);
console.error(`[${this.manifest.name}]: ${error}`);
}
},
});

this.addCommand({
id: 'import-single-highlights',
name: 'From a specific book...',
callback: () => {
new IBookHighlightsPluginSearchModal(this.app, this).open();
callback: async () => {
try {
new IBookHighlightsPluginSearchModal(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 All @@ -58,158 +58,22 @@ export default class IBookHighlightsPlugin extends Plugin {
onunload() { }

async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
this.settings = Object.assign(new AppleBooksHighlightsImportPluginSettings(), await this.loadData());

return this.settings;
}

async saveSettings() {
await this.saveData(this.settings);
}

async getBooks(): Promise<IBook[]> {
try {
const IBOOK_LIBRARY = '~/Library/Containers/com.apple.iBooksX/Data/Documents/BKLibrary/BKLibrary-1-091020131601.sqlite';
const booksSql = `
SELECT ZASSETID, ZTITLE, ZAUTHOR, ZGENRE, ZLANGUAGE, ZLASTOPENDATE, ZCOVERURL
FROM ZBKLIBRARYASSET
WHERE ZPURCHASEDATE IS NOT NULL`;

const command = `echo "${booksSql}" | sqlite3 ${IBOOK_LIBRARY} -json`;
const exec = promisify(child_process.exec);
// Issue #11 - Temporary set maxBuffer to 100MB
// TODO: Need a more efficient solution to handle large data
const { stdout } = await exec(command, { maxBuffer: 100 * 1024 * 1024 });

if (!stdout) {
throw('No books found. Looks like your Apple Books library is empty.');
}

return JSON.parse(stdout);
} catch (error) {
console.warn(`[${this.manifest.name}]:`, error);
return [];
}
}

async getAnnotations(): Promise<IBookAnnotation[]> {
try {
const IBOOK_ANNOTATION_DB = '~/Library/Containers/com.apple.iBooksX/Data/Documents/AEAnnotation/AEAnnotation_v10312011_1727_local.sqlite';
const annotationsSql = `
SELECT ZANNOTATIONASSETID, ZFUTUREPROOFING5, ZANNOTATIONREPRESENTATIVETEXT, ZANNOTATIONSELECTEDTEXT, ZANNOTATIONNOTE, ZANNOTATIONCREATIONDATE, ZANNOTATIONMODIFICATIONDATE, ZANNOTATIONSTYLE
FROM ZAEANNOTATION
WHERE ZANNOTATIONSELECTEDTEXT IS NOT NULL
AND ZANNOTATIONDELETED IS 0`;

const command = `echo "${annotationsSql}" | sqlite3 ${IBOOK_ANNOTATION_DB} -json`;
const exec = promisify(child_process.exec);
// Issue #11 - Temporary set maxBuffer to 100MB
// TODO: Need a more efficient solution to handle large data
const { stdout } = await exec(command, { maxBuffer: 100 * 1024 * 1024 });

if (stdout.length === 0) {
throw('No highlights found. Make sure you made some highlights in your Apple Books.');
}

return JSON.parse(stdout);
} catch (error) {
console.warn(`[${this.manifest.name}]:`, error);
return [];
}

}
async importHighlights(): Promise<CombinedHighlight[]> {
const books = await this.getBooks();
const annotations = await this.getAnnotations();

const resultingHighlights: CombinedHighlight[] = books.reduce((highlights: CombinedHighlight[], book: IBook) => {
const bookRelatedAnnotations: IBookAnnotation[] = annotations.filter(annotation => annotation.ZANNOTATIONASSETID === book.ZASSETID);
async aggregateAndSaveHighlights(): Promise<void> {
const highlights = await aggregateBookAndHighlightDetails();

if (bookRelatedAnnotations.length > 0) {
// Obsidian forbids adding certain characters to the title of a note, so they must be replaced with a dash (-)
// | # ^ [] \ / :
//eslint-disable-next-line
const normalizedBookTitle = book.ZTITLE.replace(/[\|\#\^\[\]\\\/\:]+/g, ' -');

highlights.push({
bookTitle: normalizedBookTitle,
bookId: book.ZASSETID,
bookAuthor: book.ZAUTHOR,
bookGenre: book.ZGENRE,
bookLanguage: book.ZLANGUAGE,
bookLastOpenedDate: book.ZLASTOPENDATE,
bookCoverUrl: book.ZCOVERURL,
annotations: bookRelatedAnnotations.map(annotation => {
return {
chapter: annotation.ZFUTUREPROOFING5,
contextualText: annotation.ZANNOTATIONREPRESENTATIVETEXT,
highlight: annotation.ZANNOTATIONSELECTEDTEXT,
note: annotation.ZANNOTATIONNOTE,
highlightStyle: annotation.ZANNOTATIONSTYLE,
highlightCreationDate: annotation.ZANNOTATIONCREATIONDATE,
highlightModificationDate: annotation.ZANNOTATIONMODIFICATIONDATE
}
})
})
}

return highlights;
}, []);

return resultingHighlights;
}

async importAndSaveHighlights(): Promise<void> {
const highlights = await this.importHighlights();

if (highlights.length === 0) {
throw ('No highlights found. Make sure you made some highlights in your Apple Books.');
}

await this.saveHighlightsToVault(highlights);
}

async saveHighlightsToVault(highlights: CombinedHighlight[]) {
const highlightsFolderPath = this.app.vault.getAbstractFileByPath(this.settings.highlightsFolder);
const isBackupEnabled = this.settings.backup;

// Backup highlights folder if backup is enabled
if (highlightsFolderPath) {
if (isBackupEnabled) {
const highlightsFilesToBackup = (await this.app.vault.adapter.list(highlightsFolderPath.path)).files;
const highlightsBackupFolder = `${this.settings.highlightsFolder}-bk-${Date.now()}`;

await this.app.vault.createFolder(highlightsBackupFolder);

highlightsFilesToBackup.forEach(async (file: string) => {
const fileName = path.basename(file);
await this.app.vault.adapter.copy(normalizePath(file), normalizePath(path.join(highlightsBackupFolder, fileName)));
});
}
await this.app.vault.delete(highlightsFolderPath, true);
}

await this.app.vault.createFolder(this.settings.highlightsFolder);

highlights.forEach(async (highlight: CombinedHighlight) => {
// TODO: Consider moving to a separate file if there are several helpers to be added
Handlebars.registerHelper('eq', (a, b) => {
if (a == b) {
return this;
}
});

Handlebars.registerHelper('dateFormat', (date, format) => {
return dayjs('2001-01-01').add(date, 's').format(format);
});

const template = Handlebars.compile(this.settings.template);
const renderedTemplate = template(highlight);

await this.app.vault.create(
normalizePath(path.join(this.settings.highlightsFolder, `${highlight.bookTitle}.md`)),
renderedTemplate
);
});
await this.saveHighlights.saveHighlightsToVault(highlights);
}
}
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "apple-books-import-highlights",
"name": "Apple Books - Import Highlights",
"version": "1.2.3",
"version": "1.3.0",
"minAppVersion": "0.15.0",
"description": "Import your Apple Books highlights and notes to Obsidian.",
"author": "bandantonio",
Expand Down
Loading