Skip to content

Commit

Permalink
Feature: Switch to spawn (instead of exec). Add basic tests. (#20)
Browse files Browse the repository at this point in the history
* refactor(spawn): replace exec with spawn. Add error handling. Update constants.

* test(db): add basic migrations to enable testing of db

* test: add basic tests for db and plugin info

* test: add tests for main functions. Decouple functions for better testability

* refactor(core): split functionality for better testability

* chore(lint): configure linter and fix basic errors

* test: add tests for basic functionality

* test: add gh action for tests and test coverage

* docs(README): add test coverage badge
  • Loading branch information
bandantonio authored Jun 28, 2024
1 parent e20c752 commit 3459e8c
Show file tree
Hide file tree
Showing 46 changed files with 6,292 additions and 4,350 deletions.
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

0 comments on commit 3459e8c

Please sign in to comment.