Skip to content

Commit 6feb099

Browse files
authored
Merge pull request #76 from readwiseio/bnkwsk/updating-notes-content-and-reader-documents-import
Updating notes content and Reader Documents import
2 parents 56d903b + 942ae34 commit 6feb099

File tree

2 files changed

+182
-69
lines changed

2 files changed

+182
-69
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@
2222
"@rollup/plugin-commonjs": "^15.1.0",
2323
"@rollup/plugin-node-resolve": "^9.0.0",
2424
"@rollup/plugin-typescript": "^6.0.0",
25+
"@types/crypto-js": "^4.2.2",
2526
"@types/node": "^14.14.2",
2627
"dotenv": "^10.0.0",
27-
"rollup-plugin-dotenv": "^0.3.0",
2828
"obsidian": "https://github.com/obsidianmd/obsidian-api/tarball/master",
2929
"rollup": "^2.32.1",
30+
"rollup-plugin-dotenv": "^0.3.0",
31+
"ts-md5": "^1.3.1",
3032
"tslib": "^2.0.3",
3133
"typescript": "^4.0.3"
3234
}

src/main.ts

Lines changed: 179 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ import {
1111
Vault
1212
} from 'obsidian';
1313
import * as zip from "@zip.js/zip.js";
14+
import { Md5 } from "ts-md5";
1415
import { StatusBar } from "./status";
1516

17+
// keep pluginVersion in sync with manifest.json
18+
const pluginVersion = "3.0.0";
1619

1720
// switch to local dev server for development
1821
const baseURL = "https://readwise.io";
@@ -31,6 +34,7 @@ interface ExportStatusResponse {
3134
booksExported: number,
3235
isFinished: boolean,
3336
taskStatus: string,
37+
artifactIds: number[],
3438
}
3539

3640
interface ReadwisePluginSettings {
@@ -143,7 +147,22 @@ export default class ReadwisePlugin extends Plugin {
143147

144148
/** Polls the Readwise API for the status of a given export;
145149
* uses recursion for polling so that it can be awaited. */
146-
async getExportStatus(statusID: number, buttonContext?: ButtonComponent) {
150+
async getExportStatus(statusID: number, buttonContext?: ButtonComponent, _processedArtifactIds?: Set<number>) {
151+
if (statusID <= this.settings.lastSavedStatusID) {
152+
console.log(`Readwise Official plugin: Already saved data from export ${statusID}`);
153+
await this.handleSyncSuccess(buttonContext);
154+
this.notice("Readwise data is already up to date", false, 4);
155+
return;
156+
}
157+
const processedArtifactIds = _processedArtifactIds ?? new Set();
158+
const downloadUnprocessedArtifacts = async (allArtifactIds: number[]) => {
159+
for (const artifactId of allArtifactIds) {
160+
if (!processedArtifactIds.has(artifactId)) {
161+
await this.downloadArtifact(artifactId, buttonContext);
162+
processedArtifactIds.add(artifactId);
163+
}
164+
}
165+
};
147166
try {
148167
const response = await fetch(
149168
// status of archive build from this endpoint
@@ -162,17 +181,29 @@ export default class ReadwisePlugin extends Plugin {
162181
if (WAITING_STATUSES.includes(data.taskStatus)) {
163182
if (data.booksExported) {
164183
const progressMsg = `Exporting Readwise data (${data.booksExported} / ${data.totalBooks}) ...`;
165-
this.notice(progressMsg);
184+
this.notice(progressMsg, false, 35, true);
166185
} else {
167186
this.notice("Building export...");
168187
}
169-
188+
// process any artifacts available while the export is still being generated
189+
await downloadUnprocessedArtifacts(data.artifactIds);
170190
// wait 1 second
171191
await new Promise(resolve => setTimeout(resolve, 1000));
172192
// then keep polling
173-
await this.getExportStatus(statusID, buttonContext);
193+
await this.getExportStatus(statusID, buttonContext, processedArtifactIds);
174194
} else if (SUCCESS_STATUSES.includes(data.taskStatus)) {
175-
await this.downloadExport(statusID, buttonContext);
195+
// make sure all artifacts are processed
196+
await downloadUnprocessedArtifacts(data.artifactIds);
197+
198+
await this.acknowledgeSyncCompleted(buttonContext);
199+
await this.handleSyncSuccess(buttonContext, "Synced!", statusID);
200+
this.notice("Readwise sync completed", true, 1, true);
201+
console.log("Readwise Official plugin: completed sync");
202+
// @ts-ignore
203+
if (this.app.isMobile) {
204+
this.notice("If you don't see all of your Readwise files, please reload the Obsidian app", true,);
205+
206+
}
176207
} else {
177208
console.log("Readwise Official plugin: unknown status in getExportStatus: ", data);
178209
await this.handleSyncError(buttonContext, "Sync failed");
@@ -279,18 +310,13 @@ export default class ReadwisePlugin extends Plugin {
279310
return {
280311
'AUTHORIZATION': `Token ${this.settings.token}`,
281312
'Obsidian-Client': `${this.getObsidianClientID()}`,
313+
'Readwise-Client-Version': pluginVersion,
282314
};
283315
}
284316

285-
async downloadExport(exportID: number, buttonContext: ButtonComponent): Promise<void> {
317+
async downloadArtifact(artifactId: number, buttonContext: ButtonComponent): Promise<void> {
286318
// download archive from this endpoint
287-
let artifactURL = `${baseURL}/api/download_artifact/${exportID}`;
288-
if (exportID <= this.settings.lastSavedStatusID) {
289-
console.log(`Readwise Official plugin: Already saved data from export ${exportID}`);
290-
await this.handleSyncSuccess(buttonContext);
291-
this.notice("Readwise data is already up to date", false, 4);
292-
return;
293-
}
319+
let artifactURL = `${baseURL}/api/v2/download_artifact/${artifactId}`;
294320

295321
let response, blob;
296322
try {
@@ -305,95 +331,136 @@ export default class ReadwisePlugin extends Plugin {
305331
} else {
306332
console.log("Readwise Official plugin: bad response in downloadExport: ", response);
307333
await this.handleSyncError(buttonContext, this.getErrorMessageFromResponse(response));
308-
return;
334+
throw new Error(`Readwise: error while fetching artifact ${artifactId}`);
309335
}
310336

311337
this.fs = this.app.vault.adapter;
312338

313339
const blobReader = new zip.BlobReader(blob);
314340
const zipReader = new zip.ZipReader(blobReader);
315341
const entries = await zipReader.getEntries();
316-
this.notice("Saving files...", false, 30);
317342
if (entries.length) {
318343
for (const entry of entries) {
319-
// will be derived from the entry's filename
344+
// will be extracted from JSON data
320345
let bookID: string;
346+
let data: Record<string, any>;
347+
348+
/** Combo of file `readwiseDir` and book name.
349+
* Example: `Readwise/Books/Name of Book.json` */
350+
const processedFileName = normalizePath(
351+
entry.filename
352+
.replace(/^Readwise/, this.settings.readwiseDir)
353+
.replace(/\.json$/, ".md")
354+
);
355+
const isReadwiseSyncFile = processedFileName === `${this.settings.readwiseDir}/${READWISE_SYNC_FILENAME}.md`;
321356

322-
/** Combo of file `readwiseDir`, book name, and book ID.
323-
* Example: `Readwise/Books/Name of Book--12345678.md` */
324-
const processedFileName = normalizePath(entry.filename.replace(/^Readwise/, this.settings.readwiseDir));
325-
326-
// derive the original name `(readwiseDir + book name).md`
327-
let originalName = processedFileName;
328-
// extracting book ID from file name
329-
let split = processedFileName.split("--");
330-
if (split.length > 1) {
331-
originalName = split.slice(0, -1).join("--") + ".md";
332-
bookID = split.last().match(/\d+/g)[0];
357+
try {
358+
const fileContent = await entry.getData(new zip.TextWriter());
359+
if (isReadwiseSyncFile) {
360+
data = {
361+
append_only_content: fileContent,
362+
};
363+
} else {
364+
data = JSON.parse(fileContent);
365+
}
333366

334-
// track the book
335-
this.settings.booksIDsMap[originalName] = bookID;
336-
}
367+
bookID = this.encodeReadwiseBookId(data.book_id) || this.encodeReaderDocumentId(data.reader_document_id);
337368

338-
try {
339369
const undefinedBook = !bookID || !processedFileName;
340-
const isReadwiseSyncFile = processedFileName === `${this.settings.readwiseDir}/${READWISE_SYNC_FILENAME}.md`;
341370
if (undefinedBook && !isReadwiseSyncFile) {
342-
throw new Error(`Book ID or file name not found for entry: ${entry.filename}`);
371+
console.error(`Error while processing entry: ${entry.filename}`);
343372
}
344-
} catch (e) {
345-
console.error(`Error while processing entry: ${entry.filename}`);
346-
}
347-
348-
// save the entry in settings to ensure that it can be
349-
// retried later when deleted files are re-synced if
350-
// the user has `settings.refreshBooks` enabled
351-
if (bookID) await this.saveSettings();
352373

353-
try {
354-
// ensure the directory exists
355-
let dirPath = processedFileName.replace(/\/*$/, '').replace(/^(.+)\/[^\/]*?$/, '$1');
356-
const exists = await this.fs.exists(dirPath);
357-
if (!exists) {
358-
await this.fs.mkdir(dirPath);
374+
// write the full document text file
375+
let isFullDocumentTextFileCreated = false;
376+
if (data.full_document_text && data.full_document_text_path) {
377+
const processedFullDocumentTextFileName = data.full_document_text_path.replace(/^Readwise/, this.settings.readwiseDir);
378+
console.log("Writing full document text", processedFullDocumentTextFileName);
379+
// track the book
380+
this.settings.booksIDsMap[processedFullDocumentTextFileName] = bookID;
381+
// ensure the directory exists
382+
await this.createDirForFile(processedFullDocumentTextFileName);
383+
if (!await this.fs.exists(processedFullDocumentTextFileName)) {
384+
// it's a new full document content file, just save it
385+
await this.fs.write(processedFullDocumentTextFileName, data.full_document_text);
386+
isFullDocumentTextFileCreated = true;
387+
} else {
388+
// full document content file already exists — overwrite it if it wasn't edited locally
389+
const existingFullDocument = await this.fs.read(processedFullDocumentTextFileName);
390+
const existingFullDocumentHash = Md5.hashStr(existingFullDocument).toString();
391+
if (existingFullDocumentHash === data.last_full_document_hash) {
392+
await this.fs.write(processedFullDocumentTextFileName, data.full_document_text);
393+
}
394+
}
359395
}
360-
// write the actual files
361-
const contents = await entry.getData(new zip.TextWriter());
362-
let contentToSave = contents;
363396

364-
if (await this.fs.exists(originalName)) {
365-
// if the file already exists we need to append content to existing one
366-
const existingContent = await this.fs.read(originalName);
367-
contentToSave = existingContent + contents;
397+
// write the actual files
398+
let contentToSave = data.full_content ?? data.append_only_content;
399+
if (contentToSave) {
400+
// track the book
401+
this.settings.booksIDsMap[processedFileName] = bookID;
402+
// ensure the directory exists
403+
await this.createDirForFile(processedFileName);
404+
if (await this.fs.exists(processedFileName)) {
405+
// if the file already exists we need to append content to existing one
406+
const existingContent = await this.fs.read(processedFileName);
407+
const existingContentHash = Md5.hashStr(existingContent).toString();
408+
if (isFullDocumentTextFileCreated) {
409+
// full document content has just been created but the highlights file exists
410+
// this means someone just wanted to resync full document content file alone
411+
// leave the existing content — otherwise we'd append all highlights once again!
412+
contentToSave = existingContent;
413+
} else if (existingContentHash !== data.last_content_hash) {
414+
// content has been modified (it differs from the previously exported full document)
415+
contentToSave = existingContent.trimEnd() + "\n" + data.append_only_content;
416+
}
417+
}
418+
await this.fs.write(processedFileName, contentToSave);
368419
}
369-
await this.fs.write(originalName, contentToSave);
420+
421+
// save the entry in settings to ensure that it can be
422+
// retried later when deleted files are re-synced if
423+
// the user has `settings.refreshBooks` enabled
424+
if (bookID) await this.saveSettings();
370425
} catch (e) {
371426
console.log(`Readwise Official plugin: error writing ${processedFileName}:`, e);
372427
this.notice(`Readwise: error while writing ${processedFileName}: ${e}`, true, 4, true);
373428
if (bookID) {
374429
// handles case where user doesn't have `settings.refreshBooks` enabled
375430
await this.addToFailedBooks(bookID);
376431
await this.saveSettings();
377-
return;
378432
}
379433
// communicate with readwise?
434+
throw new Error(`Readwise: error while processing artifact ${artifactId}`);
380435
}
381436

382-
await this.removeBooksFromRefresh([bookID]);
383-
await this.removeBookFromFailedBooks([bookID]);
437+
if (data) {
438+
await this.removeBooksFromRefresh([this.encodeReadwiseBookId(data.book_id), this.encodeReaderDocumentId(data.reader_document_id)]);
439+
await this.removeBookFromFailedBooks([this.encodeReadwiseBookId(data.book_id), this.encodeReaderDocumentId(data.reader_document_id)]);
440+
}
384441
}
385442
await this.saveSettings();
386443
}
387444
// close the ZipReader
388445
await zipReader.close();
389-
await this.acknowledgeSyncCompleted(buttonContext);
390-
await this.handleSyncSuccess(buttonContext, "Synced!", exportID);
391-
this.notice("Readwise sync completed", true, 1, true);
392-
console.log("Readwise Official plugin: completed sync");
393-
// @ts-ignore
394-
if (this.app.isMobile) {
395-
this.notice("If you don't see all of your readwise files reload obsidian app", true,);
396-
}
446+
447+
// wait for the metadata cache to process created/updated documents
448+
await new Promise<void>((resolve) => {
449+
const timeoutSeconds = 15;
450+
console.log(`Readwise Official plugin: waiting for metadata cache processing for up to ${timeoutSeconds}s...`)
451+
const timeout = setTimeout(() => {
452+
this.app.metadataCache.offref(eventRef);
453+
console.log("Readwise Official plugin: metadata cache processing timeout reached.");
454+
resolve();
455+
}, timeoutSeconds * 1000);
456+
457+
const eventRef = this.app.metadataCache.on("resolved", () => {
458+
this.app.metadataCache.offref(eventRef);
459+
clearTimeout(timeout);
460+
console.log("Readwise Official plugin: metadata cache processing has finished.");
461+
resolve();
462+
});
463+
});
397464
}
398465

399466
async acknowledgeSyncCompleted(buttonContext: ButtonComponent) {
@@ -468,6 +535,17 @@ export default class ReadwisePlugin extends Plugin {
468535

469536
console.log('Readwise Official plugin: refreshing books', { targetBookIds });
470537

538+
let requestBookIds: string[] = [];
539+
let requestReaderDocumentIds: string[] = [];
540+
targetBookIds.map(id => {
541+
const readerDocumentId = this.decodeReaderDocumentId(id);
542+
if (readerDocumentId) {
543+
requestReaderDocumentIds.push(readerDocumentId);
544+
} else {
545+
requestBookIds.push(id);
546+
}
547+
});
548+
471549
try {
472550
const response = await fetch(
473551
// add books to next archive build from this endpoint
@@ -478,7 +556,11 @@ export default class ReadwisePlugin extends Plugin {
478556
{
479557
headers: { ...this.getAuthHeaders(), 'Content-Type': 'application/json' },
480558
method: "POST",
481-
body: JSON.stringify({ exportTarget: 'obsidian', books: targetBookIds })
559+
body: JSON.stringify({
560+
exportTarget: 'obsidian',
561+
userBookIds: requestBookIds,
562+
readerDocumentIds: requestReaderDocumentIds,
563+
})
482564
}
483565
);
484566

@@ -512,7 +594,7 @@ export default class ReadwisePlugin extends Plugin {
512594
async addBookToRefresh(bookId: string) {
513595
let booksToRefresh = [...this.settings.booksToRefresh];
514596
booksToRefresh.push(bookId);
515-
console.log(`Readwise Official plugin: added book id ${bookId} to failed books`);
597+
console.log(`Readwise Official plugin: added book id ${bookId} to refresh list`);
516598
this.settings.booksToRefresh = booksToRefresh;
517599
await this.saveSettings();
518600
}
@@ -552,6 +634,14 @@ export default class ReadwisePlugin extends Plugin {
552634
}
553635
}
554636

637+
async createDirForFile(filePath: string) {
638+
const dirPath = filePath.replace(/\/*$/, '').replace(/^(.+)\/[^\/]*?$/, '$1');
639+
const exists = await this.fs.exists(dirPath);
640+
if (!exists) {
641+
await this.fs.mkdir(dirPath);
642+
}
643+
}
644+
555645
async onload() {
556646
await this.loadSettings();
557647

@@ -752,6 +842,27 @@ export default class ReadwisePlugin extends Plugin {
752842
await this.saveSettings();
753843
return true;
754844
}
845+
846+
encodeReadwiseBookId(rawBookId?: string): string | undefined {
847+
if (rawBookId) {
848+
return rawBookId.toString()
849+
}
850+
return undefined;
851+
}
852+
853+
encodeReaderDocumentId(rawReaderDocumentId?: string) : string | undefined {
854+
if (rawReaderDocumentId) {
855+
return `readerdocument:${rawReaderDocumentId}`;
856+
}
857+
return undefined;
858+
}
859+
860+
decodeReaderDocumentId(readerDocumentId?: string) : string | undefined {
861+
if (!readerDocumentId || !readerDocumentId.startsWith("readerdocument:")) {
862+
return undefined;
863+
}
864+
return readerDocumentId.replace(/^readerdocument:/, "");
865+
}
755866
}
756867

757868
class ReadwiseSettingTab extends PluginSettingTab {

0 commit comments

Comments
 (0)