From 1fd6e16cff416da413da9e5b469dd5ade42bd556 Mon Sep 17 00:00:00 2001
From: Henri Jamet <42291955+hjamet@users.noreply.github.com>
Date: Sat, 3 Jan 2026 08:34:46 +0100
Subject: [PATCH] Improve Obsidian Batch Sync. Show Progress, Storage Used on
Settings Page (#1221)
### **feat(obsidian): Enhance Sync Experience with Progress Bars and Bug
Fixes**
This pull request significantly improves the content synchronization
experience for Obsidian users by fixing a critical bug and introducing
new UI elements for better feedback and monitoring.
The previous implementation could fail with `403 Forbidden` errors when
syncing a large number of files due to server-side rate limiting. This
update addresses that issue and provides users with clear, real-time
feedback on storage usage and sync progress.
---
### Key Changes
* **Improve Sync Robustness**
Refactor `updateContentIndex` to sync files prioritized by file type (md
> pdf > image) and batched by size (10Mb) and item limits (50 items).
This respects server rate limits and ensures that large vaults can be
indexed reliably without triggering `403` errors.
* **Show Cloud Storage Usage Bar**
A progress bar has been added to the settings page to display cloud
storage usage.
* **Total Limit**: The storage limit (**10 MB** for free, **500 MB** for
premium) is now reliably determined by the `is_active` flag returned
from the `/api/v1/user` endpoint, eliminating fragile client-side
heuristics.
* **Used Space**: The used space is calculated via a **client-side
estimation** of all files configured for synchronization. This provides
a clear and immediate indicator of the vault's storage footprint.
* **Show Real-time Sync Progress Bar**
When a manual sync is triggered via the "Force Sync" button, a progress
bar now appears, providing real-time feedback on the operation.
* It displays the number of files processed against the total number of
files to be indexed or deleted.
* This is implemented using a **callback mechanism** (`onProgress`) to
cleanly communicate progress from the sync logic (`utils.ts`) to the UI
(`settings.ts`) without coupling them.
* **Auto-refresh Storage Used After Sync**
The Cloud Storage Usage bar is now automatically refreshed upon the
completion of a "Force Sync". This ensures the user immediately sees the
updated storage estimation without needing to reopen the settings panel.
---
### Visuals
---------
Co-authored-by: Debanjum
---
src/interface/obsidian/src/api.ts | 30 ++++
src/interface/obsidian/src/settings.ts | 119 ++++++++++++++--
src/interface/obsidian/src/utils.ts | 182 ++++++++++++++-----------
3 files changed, 238 insertions(+), 93 deletions(-)
create mode 100644 src/interface/obsidian/src/api.ts
diff --git a/src/interface/obsidian/src/api.ts b/src/interface/obsidian/src/api.ts
new file mode 100644
index 00000000..33d4a5d6
--- /dev/null
+++ b/src/interface/obsidian/src/api.ts
@@ -0,0 +1,30 @@
+export async function deleteContentByType(khojUrl: string, khojApiKey: string, contentType: string): Promise {
+ // Deletes all content of a given type on Khoj server for Obsidian client
+ const response = await fetch(`${khojUrl}/api/content/type/${contentType}?client=obsidian`, {
+ method: 'DELETE',
+ headers: khojApiKey ? { 'Authorization': `Bearer ${khojApiKey}` } : {},
+ });
+ if (!response.ok) {
+ const text = await response.text().catch(() => '');
+ throw new Error(`Failed to delete content type ${contentType}: ${response.status} ${text}`);
+ }
+}
+
+export async function uploadContentBatch(khojUrl: string, khojApiKey: string, files: { blob: Blob, path: string }[]): Promise {
+ // Uploads a batch of files to Khoj content endpoint
+ const formData = new FormData();
+ files.forEach(fileItem => { formData.append('files', fileItem.blob, fileItem.path); });
+
+ const response = await fetch(`${khojUrl}/api/content?client=obsidian`, {
+ method: 'PATCH',
+ headers: khojApiKey ? { 'Authorization': `Bearer ${khojApiKey}` } : {},
+ body: formData,
+ });
+
+ if (!response.ok) {
+ const text = await response.text().catch(() => '');
+ throw new Error(`Failed to upload batch: ${response.status} ${text}`);
+ }
+
+ return await response.text();
+}
diff --git a/src/interface/obsidian/src/settings.ts b/src/interface/obsidian/src/settings.ts
index e7e44321..33061791 100644
--- a/src/interface/obsidian/src/settings.ts
+++ b/src/interface/obsidian/src/settings.ts
@@ -69,6 +69,8 @@ export const DEFAULT_SETTINGS: KhojSetting = {
export class KhojSettingTab extends PluginSettingTab {
plugin: Khoj;
private chatModelSetting: Setting | null = null;
+ private storageProgressEl: HTMLProgressElement | null = null;
+ private storageProgressText: HTMLSpanElement | null = null;
constructor(app: App, plugin: Khoj) {
super(app, plugin);
@@ -229,6 +231,7 @@ export class KhojSettingTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.syncFileType.markdown = value;
await this.plugin.saveSettings();
+ this.refreshStorageDisplay();
}));
// Add setting to sync images
@@ -240,6 +243,7 @@ export class KhojSettingTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.syncFileType.images = value;
await this.plugin.saveSettings();
+ this.refreshStorageDisplay();
}));
// Add setting to sync PDFs
@@ -251,6 +255,7 @@ export class KhojSettingTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.syncFileType.pdf = value;
await this.plugin.saveSettings();
+ this.refreshStorageDisplay();
}));
// Add setting for sync interval
@@ -283,11 +288,12 @@ export class KhojSettingTab extends PluginSettingTab {
.addButton(button => button
.setButtonText('Add Folder')
.onClick(() => {
- const modal = new FolderSuggestModal(this.app, (folder: string) => {
+ const modal = new FolderSuggestModal(this.app, async (folder: string) => {
if (!this.plugin.settings.syncFolders.includes(folder)) {
this.plugin.settings.syncFolders.push(folder);
- this.plugin.saveSettings();
+ await this.plugin.saveSettings();
this.updateIncludeFolderList(includeFolderListEl);
+ this.refreshStorageDisplay();
}
});
modal.open();
@@ -305,7 +311,7 @@ export class KhojSettingTab extends PluginSettingTab {
.addButton(button => button
.setButtonText('Add Folder')
.onClick(() => {
- const modal = new FolderSuggestModal(this.app, (folder: string) => {
+ const modal = new FolderSuggestModal(this.app, async (folder: string) => {
// Don't allow excluding root folder
if (folder === '') {
new Notice('Cannot exclude the root folder');
@@ -313,8 +319,9 @@ export class KhojSettingTab extends PluginSettingTab {
}
if (!this.plugin.settings.excludeFolders.includes(folder)) {
this.plugin.settings.excludeFolders.push(folder);
- this.plugin.saveSettings();
+ await this.plugin.saveSettings();
this.updateExcludeFolderList(excludeFolderListEl);
+ this.refreshStorageDisplay();
}
});
modal.open();
@@ -337,7 +344,7 @@ export class KhojSettingTab extends PluginSettingTab {
button.removeCta();
indexVaultSetting = indexVaultSetting.setDisabled(true);
- // Show indicator for indexing in progress
+ // Show indicator for indexing in progress (animated text)
const progress_indicator = window.setInterval(() => {
if (button.buttonEl.innerText === 'Updating 🌑') {
button.setButtonText('Updating 🌘');
@@ -359,17 +366,79 @@ export class KhojSettingTab extends PluginSettingTab {
}, 300);
this.plugin.registerInterval(progress_indicator);
- this.plugin.settings.lastSync = await updateContentIndex(
- this.app.vault, this.plugin.settings, this.plugin.settings.lastSync, true, true
- );
+ // Obtain sync progress elements by id (created below)
+ const syncProgressEl = document.getElementById('khoj-sync-progress') as HTMLProgressElement | null;
+ const syncProgressText = document.getElementById('khoj-sync-progress-text') as HTMLElement | null;
- // Reset button once index is updated
- window.clearInterval(progress_indicator);
- button.setButtonText('Update');
- button.setCta();
- indexVaultSetting = indexVaultSetting.setDisabled(false);
+ if (syncProgressEl && syncProgressText) {
+ syncProgressEl.style.display = '';
+ syncProgressText.style.display = '';
+ syncProgressText.textContent = 'Preparing files...';
+ syncProgressEl.value = 0;
+ syncProgressEl.max = 1;
+ }
+
+ const onProgress = (progress: { processed: number, total: number }) => {
+ const el = document.getElementById('khoj-sync-progress') as HTMLProgressElement | null;
+ const txt = document.getElementById('khoj-sync-progress-text') as HTMLElement | null;
+ if (!el || !txt) return;
+ el.max = Math.max(progress.total, 1);
+ el.value = Math.min(progress.processed, el.max);
+ txt.textContent = `Syncing... ${progress.processed} / ${progress.total} files`;
+ };
+
+ try {
+ this.plugin.settings.lastSync = await updateContentIndex(
+ this.app.vault, this.plugin.settings, this.plugin.settings.lastSync, true, true, onProgress
+ );
+ } finally {
+ // Cleanup: hide sync progress UI
+ const el = document.getElementById('khoj-sync-progress') as HTMLProgressElement | null;
+ const txt = document.getElementById('khoj-sync-progress-text') as HTMLElement | null;
+ if (el) el.style.display = 'none';
+ if (txt) txt.style.display = 'none';
+ this.refreshStorageDisplay();
+
+ // Reset button state
+ window.clearInterval(progress_indicator);
+ button.setButtonText('Update');
+ button.setCta();
+ indexVaultSetting = indexVaultSetting.setDisabled(false);
+ }
})
);
+ // Estimated Cloud Storage (client-side)
+ const storageSetting = new Setting(containerEl)
+ .setName('Estimated Cloud Storage')
+ .setDesc('Estimated storage usage based on files configured for sync. This is a client-side estimation.')
+ .then(() => { });
+
+ // Create custom elements: progress and text for storage estimation
+ this.storageProgressEl = document.createElement('progress');
+ this.storageProgressEl.value = 0;
+ this.storageProgressEl.max = 1;
+ this.storageProgressEl.style.width = '100%';
+ this.storageProgressText = document.createElement('span');
+ this.storageProgressText.textContent = 'Calculating...';
+ storageSetting.descEl.appendChild(this.storageProgressEl);
+ storageSetting.descEl.appendChild(this.storageProgressText);
+
+ // Create progress bar for Force Sync operation (hidden by default)
+ const syncProgressEl = document.createElement('progress');
+ syncProgressEl.id = 'khoj-sync-progress';
+ syncProgressEl.value = 0;
+ syncProgressEl.max = 1;
+ syncProgressEl.style.width = '100%';
+ syncProgressEl.style.display = 'none';
+ const syncProgressText = document.createElement('span');
+ syncProgressText.id = 'khoj-sync-progress-text';
+ syncProgressText.textContent = '';
+ syncProgressText.style.display = 'none';
+ storageSetting.descEl.appendChild(syncProgressEl);
+ storageSetting.descEl.appendChild(syncProgressText);
+
+ // Call initial update
+ this.refreshStorageDisplay();
}
private connectStatusIcon() {
@@ -381,6 +450,28 @@ export class KhojSettingTab extends PluginSettingTab {
return '🔴';
}
+ private async refreshStorageDisplay() {
+ if (!this.storageProgressEl || !this.storageProgressText) return;
+
+ // Show calculating state
+ this.storageProgressEl.removeAttribute('value');
+ this.storageProgressText.textContent = 'Calculating...';
+ try {
+ const { calculateVaultSyncMetrics } = await import('./utils');
+ const metrics = await calculateVaultSyncMetrics(this.app.vault, this.plugin.settings);
+ const usedMB = (metrics.usedBytes / (1024 * 1024));
+ const totalMB = (metrics.totalBytes / (1024 * 1024));
+ const usedStr = `${usedMB.toFixed(1)} MB`;
+ const totalStr = `${totalMB.toFixed(0)} MB`;
+ this.storageProgressEl.value = metrics.usedBytes;
+ this.storageProgressEl.max = metrics.totalBytes;
+ this.storageProgressText.textContent = `${usedStr} / ${totalStr}`;
+ } catch (err) {
+ console.error('Khoj: Failed to update storage display', err);
+ this.storageProgressText.textContent = 'Estimation unavailable';
+ }
+ }
+
private async refreshModelsAndServerPreference() {
let serverSelectedModelId: string | null = null;
if (this.plugin.settings.connectedToBackend) {
@@ -480,6 +571,7 @@ export class KhojSettingTab extends PluginSettingTab {
this.plugin.settings.syncFolders = this.plugin.settings.syncFolders.filter(f => f !== folder);
await this.plugin.saveSettings();
this.updateIncludeFolderList(containerEl);
+ this.refreshStorageDisplay();
}
);
}
@@ -494,6 +586,7 @@ export class KhojSettingTab extends PluginSettingTab {
this.plugin.settings.excludeFolders = this.plugin.settings.excludeFolders.filter(f => f !== folder);
await this.plugin.saveSettings();
this.updateExcludeFolderList(containerEl);
+ this.refreshStorageDisplay();
}
);
}
diff --git a/src/interface/obsidian/src/utils.ts b/src/interface/obsidian/src/utils.ts
index 29c81bc4..e90c9dde 100644
--- a/src/interface/obsidian/src/utils.ts
+++ b/src/interface/obsidian/src/utils.ts
@@ -1,5 +1,6 @@
-import { FileSystemAdapter, Notice, Vault, Modal, TFile, request, setIcon, Editor, App, WorkspaceLeaf } from 'obsidian';
+import { FileSystemAdapter, Notice, Vault, Modal, TFile, request, setIcon, Editor, WorkspaceLeaf } from 'obsidian';
import { KhojSetting, ModelOption, ServerUserConfig, UserInfo } from 'src/settings'
+import { deleteContentByType, uploadContentBatch } from './api';
import { KhojSearchModal } from './search_modal';
export function getVaultAbsolutePath(vault: Vault): string {
@@ -60,9 +61,7 @@ export const supportedImageFilesTypes = fileTypeToExtension.image;
export const supportedBinaryFileTypes = fileTypeToExtension.pdf.concat(supportedImageFilesTypes);
export const supportedFileTypes = fileTypeToExtension.markdown.concat(supportedBinaryFileTypes);
-export async function updateContentIndex(vault: Vault, setting: KhojSetting, lastSync: Map, regenerate: boolean = false, userTriggered: boolean = false): Promise