Skip to content

Commit

Permalink
feat: sorting highlights (#38)
Browse files Browse the repository at this point in the history
This update includes:
- Add 5 sorting criteria for highlights
- Add basic tests
- Refactor tests to support sorting
- Update documentation with sorting criteria settings and examples
  • Loading branch information
bandantonio authored Jul 28, 2024
1 parent e154122 commit 2f91821
Show file tree
Hide file tree
Showing 14 changed files with 675 additions and 121 deletions.
Binary file added docs/assets/example-highlights-order.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
121 changes: 121 additions & 0 deletions docs/guide/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,127 @@ Backup highlights folder before import. The backup folder name is pre-configured
> 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)
## Highlights sorting criterion

- Default value: By creation date (from oldest to newest)

Sort highlight by a specific criterion.

The available options are:

- By creation date (from oldest to newest)
- By creation date (from newest to oldest)
- By last modification date* (from oldest to newest)
- By last modification date* (from newest to oldest)
- By location in a book

::: tip What a modification is?
Modification includes the following cases:

- Updating highlight text
- Adding or updating a note
- Changing the highlight color or style
:::

::: details Examples

Let's consider an example book with the following highlights (callouts to the left indicate the order in which the highlights were created):

![Highlights order](../assets/example-highlights-order.png)

- **By creation date (from oldest to newest)**: The highlights that were created first will be at the top.

::: details Example
```md
## Annotations
----
- 🎯 Highlight:: And now this. Christmas Day, alone on a hospital ward, failing to get through my shift.
----
- 🎯 Highlight:: At 10:30am, I looked around the ward. Nurse Janice was sprinting up and down corridor A
----
- 🎯 Highlight:: As Christmas turned to Boxing Day, I stayed up poring over my old notes and wondered whether that was where I was going wrong
----
- 🎯 Highlight:: ‘Merry Christmas, Ali. Try not to kill anyone.’
```
:::

- **By creation date (from newest to oldest)**: The highlights that were created last will be at the top.

::: details Example
```md
## Annotations
----
- 🎯 Highlight:: ‘Merry Christmas, Ali. Try not to kill anyone.’
----
- 🎯 Highlight:: As Christmas turned to Boxing Day, I stayed up poring over my old notes and wondered whether that was where I was going wrong
----
- 🎯 Highlight:: At 10:30am, I looked around the ward. Nurse Janice was sprinting up and down corridor A
----
- 🎯 Highlight:: And now this. Christmas Day, alone on a hospital ward, failing to get through my shift.
```
:::

- **By last modification date (from oldest to newest)**: The highlights that were modified first will be at the top.

::: details Example
```md
## Annotations
----
- 🎯 Highlight:: As Christmas turned to Boxing Day, I stayed up poring over my old notes and wondered whether that was where I was going wrong
- 📝 Note:: N/A
----
- 🎯 Highlight:: ‘Merry Christmas, Ali. Try not to kill anyone.’
- 📝 Note:: N/A
----
- 🎯 Highlight:: At 10:30am, I looked around the ward. Nurse Janice was sprinting up and down corridor A
- 📝 Note:: Test modification date (modified first)
----
- 🎯 Highlight:: And now this. Christmas Day, alone on a hospital ward, failing to get through my shift.
- 📝 Note:: Test modification date (modified second)
```
:::

- **By last modification date (from newest to oldest)**: The highlights that were modified last will be at the top.

::: details Example
```md
## Annotations
----
- 🎯 Highlight:: And now this. Christmas Day, alone on a hospital ward, failing to get through my shift.
- 📝 Note:: Test modification date (modified second)
----
- 🎯 Highlight:: At 10:30am, I looked around the ward. Nurse Janice was sprinting up and down corridor A
- 📝 Note:: Test modification date (modified first)
----
- 🎯 Highlight:: ‘Merry Christmas, Ali. Try not to kill anyone.’
- 📝 Note:: N/A
----
- 🎯 Highlight:: As Christmas turned to Boxing Day, I stayed up poring over my old notes and wondered whether that was where I was going wrong
- 📝 Note:: N/A
```
:::

- **By location in a book**: Highlights are sorted by their location in a book.

::: details Example
```md
## Annotations
----
- 🎯 Highlight:: ‘Merry Christmas, Ali. Try not to kill anyone.’
- 📝 Note:: N/A
----
- 🎯 Highlight:: At 10:30am, I looked around the ward. Nurse Janice was sprinting up and down corridor A
- 📝 Note:: Test modification date (modified first)
----
- 🎯 Highlight:: And now this. Christmas Day, alone on a hospital ward, failing to get through my shift.
- 📝 Note:: Test modification date (modified second)
----
- 🎯 Highlight:: As Christmas turned to Boxing Day, I stayed up poring over my old notes and wondered whether that was where I was going wrong
- 📝 Note:: N/A
```
:::
:::

## Template

- Template for highlight files.
Expand Down
11 changes: 8 additions & 3 deletions src/methods/saveHighlightsToVault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'path';
import { ICombinedBooksAndHighlights } from '../types';
import { AppleBooksHighlightsImportPluginSettings } from '../settings';
import { renderHighlightsTemplate } from './renderHighlightsTemplate';
import { sortHighlights } from 'src/methods/sortHighlights';

export default class SaveHighlights {
private app: App;
Expand Down Expand Up @@ -43,9 +44,13 @@ export default class SaveHighlights {

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

highlights.forEach(async (highlight: ICombinedBooksAndHighlights) => {
const renderedTemplate = await renderHighlightsTemplate(highlight, this.settings.template);
const filePath = path.join(this.settings.highlightsFolder, `${highlight.bookTitle}.md`);
highlights.forEach(async (combinedHighlight: ICombinedBooksAndHighlights) => {
// 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,
Expand Down
62 changes: 62 additions & 0 deletions src/methods/sortHighlights.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { IHighlight, ICombinedBooksAndHighlights, IHighlightsSortingCriterion } from 'src/types';

export const sortHighlights = (combinedHighlight: ICombinedBooksAndHighlights, highlightsSortingCriterion: string) => {
let sortedHighlights: IHighlight[] = [];

switch (highlightsSortingCriterion) {
case IHighlightsSortingCriterion.CreationDateOldToNew:
sortedHighlights = combinedHighlight.annotations.sort((a, b) => a.highlightCreationDate - b.highlightCreationDate);
break;
case IHighlightsSortingCriterion.CreationDateNewToOld:
sortedHighlights = combinedHighlight.annotations.sort((a, b) => b.highlightCreationDate - a.highlightCreationDate);
break;
case IHighlightsSortingCriterion.LastModifiedDateOldToNew:
sortedHighlights = combinedHighlight.annotations.sort((a, b) => a.highlightModificationDate - b.highlightModificationDate);
break;
case IHighlightsSortingCriterion.LastModifiedDateNewToOld:
sortedHighlights = combinedHighlight.annotations.sort((a, b) => b.highlightModificationDate - a.highlightModificationDate);
break;
case IHighlightsSortingCriterion.Book:
sortedHighlights = combinedHighlight.annotations.sort((a, b) => {
const firstHighlightLocation = highlightLocationToNumber(a.highlightLocation);
const secondHighlightLocation = highlightLocationToNumber(b.highlightLocation);

return compareLocations(firstHighlightLocation, secondHighlightLocation);
});
break;
}

return {
...combinedHighlight,
annotations: sortedHighlights
};
}

// The function converts a highlight location string to an array of numbers
export const highlightLocationToNumber = (highlightLocation: string): number[] => {
// epubcfi example structure: epubcfi(/6/2[body01]!/4/2/2/1:0)
return highlightLocation

Check warning on line 38 in src/methods/sortHighlights.ts

View workflow job for this annotation

GitHub Actions / ✅ Linter

Forbidden non-null assertion
.slice(8, -1) // Get rid of the epubcfi() wrapper
.split(',') // Split the locator into three parts: the common parent, the start subpath, and the end subpath
.slice(0, -1) // Get rid of the end subpath (third part)
.join(',') // Join the first two parts back together
.match(/(?<!\[)[/:]\d+(?!\])/g)! // Extract all the numbers (except those in square brackets) from the first two parts
.map(match => parseInt(match.slice(1))) // Get rid of the leading slash or colon and convert the string to a number
}

// The function performs lexicographic comparison of two locations to determine their relative position in a book
export const compareLocations = (firstLocation: number[], secondLocation: number[]) => {
// Loop through each element of both arrays up to the length of the shorter one
for (let i = 0; i < Math.min(firstLocation.length, secondLocation.length); i++) {
if (firstLocation[i] < secondLocation[i]) {
return -1;
}
if (firstLocation[i] > secondLocation[i]) {
return 1;
}
}

// If the loop didn't return, the arrays are equal up to the length of the shorter array
// so the function returns the difference in lengths to determine the order of the corresponding locations
return firstLocation.length - secondLocation.length;
}
29 changes: 28 additions & 1 deletion src/settings.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { App, Notice, PluginSettingTab, Setting } from 'obsidian';
import IBookHighlightsPlugin from '../main';
import defaultTemplate from './template';
import { IHighlightsSortingCriterion, IBookHighlightsPluginSettings } from './types';

export class AppleBooksHighlightsImportPluginSettings {
export class AppleBooksHighlightsImportPluginSettings implements IBookHighlightsPluginSettings {
highlightsFolder = 'ibooks-highlights';
backup = false;
importOnStart = false;
highlightsSortingCriterion = IHighlightsSortingCriterion.CreationDateOldToNew;
template = defaultTemplate;
}

Expand Down Expand Up @@ -66,6 +68,31 @@ export class IBookHighlightsSettingTab extends PluginSettingTab {
});
});

new Setting(containerEl)
.setName('Highlights sorting criterion')
.setDesc('Sort highlights by a specific criterion. Default: By creation date (from oldest to newest)')
.setClass('ibooks-highlights-sorting')
.addDropdown((dropdown) => {
const options = {
[IHighlightsSortingCriterion.CreationDateOldToNew]: 'By creation date (from oldest to newest)',
[IHighlightsSortingCriterion.CreationDateNewToOld]: 'By creation date (from newest to oldest)',
[IHighlightsSortingCriterion.LastModifiedDateOldToNew]: 'By modification date (from oldest to newest)',
[IHighlightsSortingCriterion.LastModifiedDateNewToOld]: 'By modification date (from newest to oldest)',
[IHighlightsSortingCriterion.Book]: 'By location in a book'
};

dropdown
.addOptions(options)
.setValue(this.plugin.settings.highlightsSortingCriterion)
.onChange(async (value: IHighlightsSortingCriterion) => {
console.log('value', value);

this.plugin.settings.highlightsSortingCriterion = value;
await this.plugin.saveSettings();
}
);
});

new Setting(containerEl)
.setName('Template')
.setDesc('Template for highlight files')
Expand Down
18 changes: 18 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Template } from 'handlebars';

export interface IBook {
ZASSETID: string;
ZTITLE: string;
Expand Down Expand Up @@ -40,3 +42,19 @@ export interface ICombinedBooksAndHighlights {
bookCoverUrl: string;
annotations: IHighlight[];
}

export enum IHighlightsSortingCriterion {
CreationDateOldToNew = 'creationDateOldToNew',

Check warning on line 47 in src/types.ts

View workflow job for this annotation

GitHub Actions / ✅ Linter

'CreationDateOldToNew' is defined but never used
CreationDateNewToOld = 'creationDateNewToOld',

Check warning on line 48 in src/types.ts

View workflow job for this annotation

GitHub Actions / ✅ Linter

'CreationDateNewToOld' is defined but never used
LastModifiedDateOldToNew = 'lastModifiedDateOldToNew',

Check warning on line 49 in src/types.ts

View workflow job for this annotation

GitHub Actions / ✅ Linter

'LastModifiedDateOldToNew' is defined but never used
LastModifiedDateNewToOld = 'lastModifiedDateNewToOld',

Check warning on line 50 in src/types.ts

View workflow job for this annotation

GitHub Actions / ✅ Linter

'LastModifiedDateNewToOld' is defined but never used
Book = 'book'

Check warning on line 51 in src/types.ts

View workflow job for this annotation

GitHub Actions / ✅ Linter

'Book' is defined but never used
}

export interface IBookHighlightsPluginSettings {
highlightsFolder: string;
backup: boolean;
importOnStart: boolean;
highlightsSortingCriterion: IHighlightsSortingCriterion;
template: Template;
}
28 changes: 16 additions & 12 deletions src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,22 @@ import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import Handlebars from 'handlebars';

export const calculateAppleDate = (date: number) => {
dayjs.extend(utc);
dayjs.extend(timezone);

const timeZone = dayjs.tz.guess();

const APPLE_EPOCH_START = new Date('2001-01-01').getTime();
const dateInMilliseconds = date * 1000;
const calculatedDate = dayjs(APPLE_EPOCH_START)
.add(dateInMilliseconds, 'ms')
.tz(timeZone || 'UTC');

return calculatedDate;
}

(() => {
// TODO: Consider moving to a separate file if there are several helpers to be added
Handlebars.registerHelper('eq', function (a, b) {
if (a == b) {
return this;
Expand All @@ -13,17 +27,7 @@ import Handlebars from 'handlebars';

// TODO: Consider using out-of-the-box date validation via https://day.js.org/docs/en/parse/is-valid
Handlebars.registerHelper('dateFormat', (date, format) => {
dayjs.extend(utc);
dayjs.extend(timezone);

const timeZone = dayjs.tz.guess();

const APPLE_EPOCH_START = new Date('2001-01-01').getTime();
const dateInMilliseconds = date * 1000;
const calculatedDate = dayjs(APPLE_EPOCH_START)
.add(dateInMilliseconds, 'ms')
.tz(timeZone || 'UTC');

const calculatedDate = calculateAppleDate(date);
const formattedDate = calculatedDate.format(format);

return formattedDate;
Expand Down
6 changes: 3 additions & 3 deletions test/aggregateDetails.spec.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { describe, expect, test, vi } from 'vitest';
import { aggregateBookAndHighlightDetails } from '../src/methods/aggregateDetails';
import * as db from '../src/db';
import { booksToAggregate, annotationsToAggregate, aggregatedHighlights } from './mocks/aggregatedDetailsData';
import { booksToAggregate, annotationsToAggregate, aggregatedUnsortedHighlights } from './mocks/aggregatedDetailsData';
import { IBook, IBookAnnotation } from '../src/types';

describe('aggregateBookAndHighlightDetails', () => {
test('Should return an array of aggregated highlights when a book has highlights', async () => {
test('Should return an array of aggregated unsorted highlights when a book has highlights', async () => {
vi.spyOn(db, 'dbRequest').mockResolvedValue(booksToAggregate as IBook[]);
vi.spyOn(db, 'annotationsRequest').mockResolvedValue(annotationsToAggregate as IBookAnnotation[]);

const books = await aggregateBookAndHighlightDetails();

expect(books).toEqual(aggregatedHighlights);
expect(books).toEqual(aggregatedUnsortedHighlights);
});

test('Should return an empty array when a book has no highlights', async () => {
Expand Down
Loading

0 comments on commit 2f91821

Please sign in to comment.