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

feat: sorting highlights #38

Merged
merged 1 commit into from
Jul 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
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