Repository: renehernandez/obsidian-readwise (ts)

Issue:
Setting option to automatically sync Readwise changes on a given interval
This option will automatically sync from Readwise on a certain `interval` (either hours or minutes, most likely hours) while the Obsidian app is open.

This setting in combination with the `Sync on Startup` + the command to run manually covers the most common scenarios for syncing.

Pull request description:
Sync on interval
## Description

Fix #33 

## Other Changes

* Introduces a Promises Queue to reduce the chance of conflicts when running on interval or manually

Patch:
diff --git a/src/index.ts b/src/index.ts
index 280ec05..c604fde 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -12,16 +12,18 @@ import { FileDoc } from "./fileDoc";
 import { TokenManager } from "./tokenManager";
 import { FileSystemHandler } from "./fileSystem";
 import { DateFactory } from "./date";
+import PromiseQueue from "./promiseQueue";
 
 
 export default class ObsidianReadwisePlugin extends Plugin {
 	public settings: ObsidianReadwiseSettings;
     public tokenManager: TokenManager;
-	public intervalID: number;
+	public timeoutIdSync: number;
 
 	private state: PluginState = PluginState.idle;
     private api: ReadwiseApi;
     private mode: AppMode;
+    private promiseQueue: PromiseQueue;
 
 
     setState(state: PluginState) {
@@ -37,6 +39,7 @@ export default class ObsidianReadwisePlugin extends Plugin {
         this.tokenManager = new TokenManager();
         this.mode = this.app.isMobile ? new MobileMode(this) : new DesktopMode(this);
         this.mode.onload();
+        this.promiseQueue = new PromiseQueue();
 
         await this.loadSettings();
 
@@ -46,23 +49,23 @@ export default class ObsidianReadwisePlugin extends Plugin {
 		this.addCommand({
             id: "sync",
             name: "Sync highlights",
-            callback: async () => {
-                if (!(await this.initializeApi())) {
-                    return;
-                }
-                await this.syncReadwise(this.settings.lastUpdate);
-        }});
-
-        if (!(await this.initializeApi())) {
-            return;
-        }
+            callback: () => this.promiseQueue.addTask(() => this.syncReadwise(this.settings.lastUpdate)),
+        });
 
 		if (this.settings.syncOnBoot) {
 			await this.syncReadwise(this.settings.lastUpdate);
 		}
+
+        if (this.settings.autoSyncInterval > 0) {
+            const now = new Date();
+            const diff = this.settings.autoSyncInterval - (Math.round(((now.getTime() - this.settings.lastUpdate) / 1000) / 3600 ));
+
+            this.startAutoSync(diff <= 0 && !this.settings.syncOnBoot ? 0 : diff);
+        }
 	}
 
 	async onunload() {
+        window.clearTimeout(this.timeoutIdSync);
 		await this.saveSettings();
 	}
 
@@ -75,6 +78,9 @@ export default class ObsidianReadwisePlugin extends Plugin {
 	}
 
     async syncReadwise(since?: number, to?: number) {
+        if (!(await this.initializeApi())) {
+            return;
+        }
         const documentsResults = await this.getNewHighlightsInDocuments(since, to)
 
         if (documentsResults.isErr()) {
@@ -143,6 +149,24 @@ export default class ObsidianReadwisePlugin extends Plugin {
         this.api = new ReadwiseApi(token, new DateFactory());
         return true
     }
+
+    startAutoSync(hours?: number) {
+        this.timeoutIdSync = window.setTimeout(
+            () => {
+                this.promiseQueue.addTask(() => this.syncReadwise(this.settings.lastUpdate));
+                this.startAutoSync();
+            },
+            (hours ?? this.settings.autoSyncInterval) * 3_600_000
+        );
+    }
+
+    clearAutoSync(): boolean {
+        if (this.timeoutIdSync) {
+            window.clearTimeout(this.timeoutIdSync);
+            return true;
+        }
+        return false;
+    }
 }
 
 interface AppMode {
diff --git a/src/promiseQueue.ts b/src/promiseQueue.ts
new file mode 100644
index 0000000..176e3a2
--- /dev/null
+++ b/src/promiseQueue.ts
@@ -0,0 +1,19 @@
+export default class PromiseQueue {
+    tasks: (() => Promise<any>)[] = [];
+
+    addTask(task: () => Promise<any>) {
+        this.tasks.push(task);
+        if (this.tasks.length === 1) {
+            this.handleTask();
+        }
+    }
+    
+    async handleTask() {
+        if (this.tasks.length > 0) {
+            this.tasks[0]().finally(() => {
+                this.tasks.shift();
+                this.handleTask();
+            });
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/settings.ts b/src/settings.ts
index 40aba1e..5bbeb31 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -1,5 +1,6 @@
 export interface ObsidianReadwiseSettings {
     syncOnBoot: boolean;
+    autoSyncInterval: number;
     lastUpdate: number;
 	disableNotifications: boolean;
     headerTemplatePath: string;
@@ -16,6 +17,7 @@ export class ObsidianReadwiseSettingsGenerator {
     static defaultSettings(): ObsidianReadwiseSettings {
         return {
             syncOnBoot: false,
+            autoSyncInterval: 0,
             disableNotifications: false,
             headerTemplatePath: "",
             highlightTemplatePath: "",
diff --git a/src/settingsTab.ts b/src/settingsTab.ts
index ac39c8d..bc61263 100644
--- a/src/settingsTab.ts
+++ b/src/settingsTab.ts
@@ -1,4 +1,4 @@
-import { App, PluginSettingTab, Setting } from "obsidian";
+import { App, Notice, PluginSettingTab, Setting } from "obsidian";
 import type ObsidianReadwisePlugin from '.';
 import type { TokenManager } from "./tokenManager";
 
@@ -18,10 +18,11 @@ export class ObsidianReadwiseSettingsTab extends PluginSettingTab {
 
 		containerEl.empty();
 
-		containerEl.createEl('h2', {text: 'Readwise Settings'});
+		containerEl.createEl('h2', {text: 'Readwise Community Settings'});
 
         this.apiTokenSetting();
         this.syncOnBoot();
+        this.syncOnInterval();
         this.highlightStoragePath();
         this.headerTemplatePath();
         this.highlightTemplatePath();
@@ -82,6 +83,38 @@ export class ObsidianReadwiseSettingsTab extends PluginSettingTab {
             }))
     }
 
+    syncOnInterval() {
+        new Setting(this.containerEl)
+            .setName('Sync on Interval')
+            .setDesc('Sync updated highlights on interval (hours). To disable automatic sync specify a negative value or zero (default)')
+            .addText(text => text
+                .setValue(String(this.plugin.settings.autoSyncInterval))
+                .onChange(async value => {
+                    if (!isNaN(Number(value))) {
+                        this.plugin.settings.autoSyncInterval = Number(value);
+                        await this.plugin.saveSettings();
+
+                        if (this.plugin.settings.autoSyncInterval > 0) {
+                            this.plugin.clearAutoSync();
+                            this.plugin.startAutoSync(this.plugin.settings.autoSyncInterval);
+                            new Notice(
+                                `Automatic sync enabled! Every ${this.plugin.settings.autoSyncInterval} hours.`
+                            )
+                        }
+                        else if (this.plugin.settings.autoSyncInterval <= 0 && this.plugin.timeoutIdSync) {
+                            this.plugin.clearAutoSync();
+                            new Notice(
+                                "Automatic sync disabled!"
+                            )
+                        }
+                    }
+                    else {
+                        new Notice("Please specify a valid number.")
+                    }
+                })
+        );
+    }
+
     headerTemplatePath() {
         new Setting(this.containerEl)
             .setName('Custom Header Template Path')


Test patch:
diff --git a/tests/settings.ts b/tests/settings.ts
index 2a2950c..82a76ff 100644
--- a/tests/settings.ts
+++ b/tests/settings.ts
@@ -31,6 +31,10 @@ describe("Settings", () => {
         it('highlight template path is empty', () => {
             assert.isEmpty(settings.highlightTemplatePath);
         });
+
+        it('intervalSync is set to zero', () => {
+            assert.equal(settings.autoSyncInterval, 0);
+        })
     });
 
     context("withData", () => {
@@ -38,6 +42,7 @@ describe("Settings", () => {
         before(() => {
             settings = ObsidianReadwiseSettingsGenerator.withData({
                 lastUpdate: 10,
+                autoSyncInterval: 3,
                 syncOnBoot: true,
                 headerTemplatePath: 'Hello World',
                 highlightTemplatePath: 'Good Bye',
@@ -64,5 +69,9 @@ describe("Settings", () => {
         it('overrides the disableNotifications field', () => {
             assert.isTrue(settings.disableNotifications);
         });
+
+        it('overrides the autoSyncInterval field', () => {
+            assert.equal(settings.autoSyncInterval, 3);
+        })
     });
 })
\ No newline at end of file