Skip to content
Draft
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
39 changes: 20 additions & 19 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import * as zip from "@zip.js/zip.js";
import { Md5 } from "ts-md5";
import { getErrorDetailsFromResponse, ReadwiseSyncError } from "./errors";
import { readwiseSyncFilePath } from "./paths";
import { readwiseSyncFilePath, stablePathForBookExport } from "./paths";
import { StatusBar } from "./status";

// keep pluginVersion in sync with manifest.json
Expand Down Expand Up @@ -398,24 +398,25 @@ export default class ReadwisePlugin extends Plugin {
}
}

// write the actual files
let contentToSave = data.full_content ?? data.append_only_content;
if (contentToSave) {
// track the book
this.settings.booksIDsMap[processedFileName] = bookID;
// ensure the directory exists
await this.createDirForFile(processedFileName);
if (await this.fs.exists(processedFileName)) {
// if the file already exists we need to append content to existing one
const existingContent = await this.fs.read(processedFileName);
const existingContentHash = Md5.hashStr(existingContent).toString();
if (existingContentHash !== data.last_content_hash) {
// content has been modified (it differs from the previously exported full document)
contentToSave = existingContent.trimEnd() + "\n" + data.append_only_content;
}
}
await this.fs.write(processedFileName, contentToSave);
}
// write the actual files
let contentToSave = data.full_content ?? data.append_only_content;
if (contentToSave) {
const pathToSave = stablePathForBookExport(this.settings.booksIDsMap, bookID, processedFileName);
// track the book
this.settings.booksIDsMap[pathToSave] = bookID;
// ensure the directory exists
await this.createDirForFile(pathToSave);
if (await this.fs.exists(pathToSave)) {
// if the file already exists we need to append content to existing one
const existingContent = await this.fs.read(pathToSave);
const existingContentHash = Md5.hashStr(existingContent).toString();
if (existingContentHash !== data.last_content_hash) {
// content has been modified (it differs from the previously exported full document)
contentToSave = existingContent.trimEnd() + "\n" + data.append_only_content;
}
}
await this.fs.write(pathToSave, contentToSave);
}

// save the entry in settings to ensure that it can be
// retried later when deleted files are re-synced if
Expand Down
55 changes: 55 additions & 0 deletions src/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,58 @@ export function readwiseSyncFilePath(
): string {
return normalizePath(`${readwiseDir}/${READWISE_SYNC_FILENAME}.md`);
}

export type BooksIDsMap = { [filePath: string]: string };

function isFullDocumentContentPath(path: string): boolean {
return path.split("/").includes("Full Document Contents");
}

function hasNumericSuffixBeforeExtension(path: string): boolean {
return /\s\(\d+\)\.md$/i.test(path);
}

function compareCandidatePaths(left: string, right: string): number {
const leftHasNumericSuffix = hasNumericSuffixBeforeExtension(left);
const rightHasNumericSuffix = hasNumericSuffixBeforeExtension(right);

if (leftHasNumericSuffix !== rightHasNumericSuffix) {
return leftHasNumericSuffix ? 1 : -1;
}

if (left.length !== right.length) {
return left.length - right.length;
}

return left.localeCompare(right);
}

/**
* Resolve the vault path to update for an exported Readwise book.
*
* Readwise export artifacts can occasionally contain a new filename for a book
* that already exists in the vault, such as `Book Title (467).md`. When the
* settings map already knows a path for the same book ID, keep updating that
* stable path instead of creating a duplicate suffixed file.
*/
export function stablePathForBookExport(
booksIDsMap: BooksIDsMap,
bookID: string,
exportedPath: string,
): string {
if (!bookID || booksIDsMap[exportedPath] === bookID) {
return exportedPath;
}

const exportedIsFullDocumentContent = isFullDocumentContentPath(exportedPath);
const candidates = Object.keys(booksIDsMap).filter((path) => {
return booksIDsMap[path] === bookID
&& isFullDocumentContentPath(path) === exportedIsFullDocumentContent;
});

if (!candidates.length) {
return exportedPath;
}

return candidates.sort(compareCandidatePaths)[0];
}
65 changes: 64 additions & 1 deletion tests/paths.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { readwiseSyncFilePath } = require("../.test-build/src/paths");
const { readwiseSyncFilePath, stablePathForBookExport } = require("../.test-build/src/paths");

// Faithful copy of Obsidian's normalizePath (the real one lives in the
// "obsidian" runtime, which is not available under `node --test`).
Expand Down Expand Up @@ -35,3 +35,66 @@ test("sync file path matches the processed entry for default and nested base fol
assert.equal(readwiseSyncFilePath(dir, normalizePath), processedSyncName(dir));
}
});

test("book export reuses an existing path for the same book ID", () => {
const booksIDsMap = {
"Readwise/Books/Seven Brief Lessons on Physics.md": "book:35774970",
};

assert.equal(
stablePathForBookExport(
booksIDsMap,
"book:35774970",
"Readwise/Books/Seven Brief Lessons on Physics (467).md",
),
"Readwise/Books/Seven Brief Lessons on Physics.md",
);
});

test("book export preserves a user-renamed path for the same book ID", () => {
const booksIDsMap = {
"Readwise/Books/Favorites/Physics notes.md": "book:35774970",
};

assert.equal(
stablePathForBookExport(
booksIDsMap,
"book:35774970",
"Readwise/Books/Seven Brief Lessons on Physics (467).md",
),
"Readwise/Books/Favorites/Physics notes.md",
);
});

test("book export prefers a non-suffixed candidate when duplicates already exist", () => {
const booksIDsMap = {
"Readwise/Books/Seven Brief Lessons on Physics (467).md": "book:35774970",
"Readwise/Books/Seven Brief Lessons on Physics.md": "book:35774970",
"Readwise/Books/Seven Brief Lessons on Physics (485).md": "book:35774970",
};

assert.equal(
stablePathForBookExport(
booksIDsMap,
"book:35774970",
"Readwise/Books/Seven Brief Lessons on Physics (486).md",
),
"Readwise/Books/Seven Brief Lessons on Physics.md",
);
});

test("book export keeps full document content paths separate from highlight paths", () => {
const booksIDsMap = {
"Readwise/Books/Seven Brief Lessons on Physics.md": "book:35774970",
"Readwise/Full Document Contents/Books/Seven Brief Lessons on Physics.md": "book:35774970",
};

assert.equal(
stablePathForBookExport(
booksIDsMap,
"book:35774970",
"Readwise/Full Document Contents/Books/Seven Brief Lessons on Physics (467).md",
),
"Readwise/Full Document Contents/Books/Seven Brief Lessons on Physics.md",
);
});