@@ -11,8 +11,11 @@ import {
11
11
Vault
12
12
} from 'obsidian' ;
13
13
import * as zip from "@zip.js/zip.js" ;
14
+ import { Md5 } from "ts-md5" ;
14
15
import { StatusBar } from "./status" ;
15
16
17
+ // keep pluginVersion in sync with manifest.json
18
+ const pluginVersion = "3.0.0" ;
16
19
17
20
// switch to local dev server for development
18
21
const baseURL = "https://readwise.io" ;
@@ -31,6 +34,7 @@ interface ExportStatusResponse {
31
34
booksExported : number ,
32
35
isFinished : boolean ,
33
36
taskStatus : string ,
37
+ artifactIds : number [ ] ,
34
38
}
35
39
36
40
interface ReadwisePluginSettings {
@@ -143,7 +147,22 @@ export default class ReadwisePlugin extends Plugin {
143
147
144
148
/** Polls the Readwise API for the status of a given export;
145
149
* 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
+ } ;
147
166
try {
148
167
const response = await fetch (
149
168
// status of archive build from this endpoint
@@ -162,17 +181,29 @@ export default class ReadwisePlugin extends Plugin {
162
181
if ( WAITING_STATUSES . includes ( data . taskStatus ) ) {
163
182
if ( data . booksExported ) {
164
183
const progressMsg = `Exporting Readwise data (${ data . booksExported } / ${ data . totalBooks } ) ...` ;
165
- this . notice ( progressMsg ) ;
184
+ this . notice ( progressMsg , false , 35 , true ) ;
166
185
} else {
167
186
this . notice ( "Building export..." ) ;
168
187
}
169
-
188
+ // process any artifacts available while the export is still being generated
189
+ await downloadUnprocessedArtifacts ( data . artifactIds ) ;
170
190
// wait 1 second
171
191
await new Promise ( resolve => setTimeout ( resolve , 1000 ) ) ;
172
192
// then keep polling
173
- await this . getExportStatus ( statusID , buttonContext ) ;
193
+ await this . getExportStatus ( statusID , buttonContext , processedArtifactIds ) ;
174
194
} 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
+ }
176
207
} else {
177
208
console . log ( "Readwise Official plugin: unknown status in getExportStatus: " , data ) ;
178
209
await this . handleSyncError ( buttonContext , "Sync failed" ) ;
@@ -279,18 +310,13 @@ export default class ReadwisePlugin extends Plugin {
279
310
return {
280
311
'AUTHORIZATION' : `Token ${ this . settings . token } ` ,
281
312
'Obsidian-Client' : `${ this . getObsidianClientID ( ) } ` ,
313
+ 'Readwise-Client-Version' : pluginVersion ,
282
314
} ;
283
315
}
284
316
285
- async downloadExport ( exportID : number , buttonContext : ButtonComponent ) : Promise < void > {
317
+ async downloadArtifact ( artifactId : number , buttonContext : ButtonComponent ) : Promise < void > {
286
318
// 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 } ` ;
294
320
295
321
let response , blob ;
296
322
try {
@@ -305,95 +331,136 @@ export default class ReadwisePlugin extends Plugin {
305
331
} else {
306
332
console . log ( "Readwise Official plugin: bad response in downloadExport: " , response ) ;
307
333
await this . handleSyncError ( buttonContext , this . getErrorMessageFromResponse ( response ) ) ;
308
- return ;
334
+ throw new Error ( `Readwise: error while fetching artifact ${ artifactId } ` ) ;
309
335
}
310
336
311
337
this . fs = this . app . vault . adapter ;
312
338
313
339
const blobReader = new zip . BlobReader ( blob ) ;
314
340
const zipReader = new zip . ZipReader ( blobReader ) ;
315
341
const entries = await zipReader . getEntries ( ) ;
316
- this . notice ( "Saving files..." , false , 30 ) ;
317
342
if ( entries . length ) {
318
343
for ( const entry of entries ) {
319
- // will be derived from the entry's filename
344
+ // will be extracted from JSON data
320
345
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 ( / ^ R e a d w i s e / , this . settings . readwiseDir )
353
+ . replace ( / \. j s o n $ / , ".md" )
354
+ ) ;
355
+ const isReadwiseSyncFile = processedFileName === `${ this . settings . readwiseDir } /${ READWISE_SYNC_FILENAME } .md` ;
321
356
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 ( / ^ R e a d w i s e / , 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
+ }
333
366
334
- // track the book
335
- this . settings . booksIDsMap [ originalName ] = bookID ;
336
- }
367
+ bookID = this . encodeReadwiseBookId ( data . book_id ) || this . encodeReaderDocumentId ( data . reader_document_id ) ;
337
368
338
- try {
339
369
const undefinedBook = ! bookID || ! processedFileName ;
340
- const isReadwiseSyncFile = processedFileName === `${ this . settings . readwiseDir } /${ READWISE_SYNC_FILENAME } .md` ;
341
370
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 } `) ;
343
372
}
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 ( ) ;
352
373
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 ( / ^ R e a d w i s e / , 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
+ }
359
395
}
360
- // write the actual files
361
- const contents = await entry . getData ( new zip . TextWriter ( ) ) ;
362
- let contentToSave = contents ;
363
396
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 ) ;
368
419
}
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 ( ) ;
370
425
} catch ( e ) {
371
426
console . log ( `Readwise Official plugin: error writing ${ processedFileName } :` , e ) ;
372
427
this . notice ( `Readwise: error while writing ${ processedFileName } : ${ e } ` , true , 4 , true ) ;
373
428
if ( bookID ) {
374
429
// handles case where user doesn't have `settings.refreshBooks` enabled
375
430
await this . addToFailedBooks ( bookID ) ;
376
431
await this . saveSettings ( ) ;
377
- return ;
378
432
}
379
433
// communicate with readwise?
434
+ throw new Error ( `Readwise: error while processing artifact ${ artifactId } ` ) ;
380
435
}
381
436
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
+ }
384
441
}
385
442
await this . saveSettings ( ) ;
386
443
}
387
444
// close the ZipReader
388
445
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
+ } ) ;
397
464
}
398
465
399
466
async acknowledgeSyncCompleted ( buttonContext : ButtonComponent ) {
@@ -468,6 +535,17 @@ export default class ReadwisePlugin extends Plugin {
468
535
469
536
console . log ( 'Readwise Official plugin: refreshing books' , { targetBookIds } ) ;
470
537
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
+
471
549
try {
472
550
const response = await fetch (
473
551
// add books to next archive build from this endpoint
@@ -478,7 +556,11 @@ export default class ReadwisePlugin extends Plugin {
478
556
{
479
557
headers : { ...this . getAuthHeaders ( ) , 'Content-Type' : 'application/json' } ,
480
558
method : "POST" ,
481
- body : JSON . stringify ( { exportTarget : 'obsidian' , books : targetBookIds } )
559
+ body : JSON . stringify ( {
560
+ exportTarget : 'obsidian' ,
561
+ userBookIds : requestBookIds ,
562
+ readerDocumentIds : requestReaderDocumentIds ,
563
+ } )
482
564
}
483
565
) ;
484
566
@@ -512,7 +594,7 @@ export default class ReadwisePlugin extends Plugin {
512
594
async addBookToRefresh ( bookId : string ) {
513
595
let booksToRefresh = [ ...this . settings . booksToRefresh ] ;
514
596
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 ` ) ;
516
598
this . settings . booksToRefresh = booksToRefresh ;
517
599
await this . saveSettings ( ) ;
518
600
}
@@ -552,6 +634,14 @@ export default class ReadwisePlugin extends Plugin {
552
634
}
553
635
}
554
636
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
+
555
645
async onload ( ) {
556
646
await this . loadSettings ( ) ;
557
647
@@ -752,6 +842,27 @@ export default class ReadwisePlugin extends Plugin {
752
842
await this . saveSettings ( ) ;
753
843
return true ;
754
844
}
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 ( / ^ r e a d e r d o c u m e n t : / , "" ) ;
865
+ }
755
866
}
756
867
757
868
class ReadwiseSettingTab extends PluginSettingTab {
0 commit comments