mirror of
https://github.com/zoorpha/rustshare-obsidian-plugin.git
synced 2026-07-22 07:48:14 +00:00
Fix Obsidian plugin verification issues, deprecated APIs, popout compatibility, any types, and switch fetch to requestUrl
This commit is contained in:
parent
b7021c1367
commit
b378248d73
14 changed files with 160 additions and 114 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
import { builtinModules } from "module";
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
|
|
@ -22,7 +22,7 @@ const context = await esbuild.context({
|
|||
'electron',
|
||||
'@codemirror/*',
|
||||
'lezer',
|
||||
...builtins],
|
||||
...builtinModules],
|
||||
format: 'cjs',
|
||||
target: 'es2018',
|
||||
logLevel: "info",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"name": "RustShare Vault Sync",
|
||||
"version": "0.1.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Sync local Obsidian vaults to RustShare. Obsidian is a trademark of Dynalist Inc. RustShare is not affiliated with, endorsed by, or sponsored by Obsidian.",
|
||||
"description": "Sync local vaults to RustShare. RustShare is not affiliated with, endorsed by, or sponsored by Dynalist Inc.",
|
||||
"author": "RustShare",
|
||||
"authorUrl": "https://rustshare.io",
|
||||
"fundingUrl": "",
|
||||
|
|
|
|||
14
package-lock.json
generated
14
package-lock.json
generated
|
|
@ -11,7 +11,6 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"builtin-modules": "4.0.0",
|
||||
"esbuild": "0.24.0",
|
||||
"obsidian": "1.7.2",
|
||||
"tslib": "2.8.0",
|
||||
|
|
@ -1123,19 +1122,6 @@
|
|||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/builtin-modules": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-4.0.0.tgz",
|
||||
"integrity": "sha512-p1n8zyCkt1BVrKNFymOHjcDSAl7oq/gUvfgULv2EblgpPVQlQr9yHnWjg9IJ2MhfwPqiYqMMrr01OY7yQoK2yA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "rustshare-vault-sync",
|
||||
"version": "0.1.0",
|
||||
"description": "RustShare Vault Sync for Obsidian. Obsidian is a trademark of Dynalist Inc. RustShare is not affiliated with, endorsed by, or sponsored by Obsidian.",
|
||||
"description": "Sync local vaults to RustShare. RustShare is not affiliated with, endorsed by, or sponsored by Dynalist Inc.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
|
|
@ -20,7 +20,6 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"builtin-modules": "4.0.0",
|
||||
"esbuild": "0.24.0",
|
||||
"obsidian": "1.7.2",
|
||||
"tslib": "2.8.0",
|
||||
|
|
|
|||
63
src/api.ts
63
src/api.ts
|
|
@ -1,5 +1,15 @@
|
|||
// Disclaimer: Obsidian is a trademark of Dynalist Inc. RustShare is not affiliated with, endorsed by, or sponsored by Obsidian.
|
||||
// RustShare Vault Sync — API client for the RustShare vault sync backend.
|
||||
import { requestUrl } from 'obsidian';
|
||||
|
||||
export interface APIError extends Error {
|
||||
status?: number;
|
||||
retry_after?: number;
|
||||
error?: string;
|
||||
path?: string;
|
||||
client_rev?: number;
|
||||
current_rev?: number;
|
||||
server_sha256?: string;
|
||||
resolution?: string;
|
||||
}
|
||||
|
||||
export interface Vault {
|
||||
id: string;
|
||||
|
|
@ -130,14 +140,6 @@ export class RustShareAPI {
|
|||
}
|
||||
}
|
||||
|
||||
private fetchWithTimeout(url: string, init: RequestInit, timeoutMs = 30000): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
return fetch(url, { ...init, signal: controller.signal }).finally(() => {
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
}
|
||||
|
||||
private async request<T>(method: string, endpoint: string, body?: unknown, extraHeaders?: Record<string, string>): Promise<T>;
|
||||
private async request(method: string, endpoint: string, body?: unknown, extraHeaders?: Record<string, string>): Promise<unknown> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
|
@ -149,18 +151,27 @@ export class RustShareAPI {
|
|||
}
|
||||
Object.assign(headers, extraHeaders);
|
||||
|
||||
if (body !== undefined && !(body instanceof ArrayBuffer) && !headers['Content-Type']) {
|
||||
const contentType = headers['Content-Type'];
|
||||
if (body !== undefined && !(body instanceof ArrayBuffer) && !contentType) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const response = await this.fetchWithTimeout(this.buildUrl(endpoint), {
|
||||
const response = await requestUrl({
|
||||
url: this.buildUrl(endpoint),
|
||||
method,
|
||||
headers,
|
||||
body: body instanceof ArrayBuffer ? body : body !== undefined ? JSON.stringify(body) : undefined,
|
||||
contentType: contentType || (body !== undefined && !(body instanceof ArrayBuffer) ? 'application/json' : undefined),
|
||||
throw: false,
|
||||
});
|
||||
|
||||
const headersLower = Object.keys(response.headers).reduce((acc, key) => {
|
||||
acc[key.toLowerCase()] = response.headers[key];
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
if (response.status === 409) {
|
||||
const conflict = await response.json().catch(() => ({})) as Partial<ConflictError>;
|
||||
const conflict = (response.json || {}) as Partial<ConflictError>;
|
||||
const error: ConflictError = {
|
||||
error: 'conflict',
|
||||
message: conflict.message ?? 'Conflict detected',
|
||||
|
|
@ -170,30 +181,30 @@ export class RustShareAPI {
|
|||
server_sha256: conflict.server_sha256,
|
||||
resolution: conflict.resolution ?? 'create_conflict_copy',
|
||||
};
|
||||
const err = new Error(error.message || 'Conflict');
|
||||
const err = new Error(error.message || 'Conflict') as APIError;
|
||||
Object.assign(err, error);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers.get('retry-after') || response.headers.get('Retry-After');
|
||||
const retryAfter = headersLower['retry-after'];
|
||||
let retryAfterSeconds: number | undefined;
|
||||
if (retryAfter) {
|
||||
const trimmed = retryAfter.trim();
|
||||
// Only parse if it's a pure integer (delta-seconds). Ignore HTTP-date strings.
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
retryAfterSeconds = parseInt(trimmed, 10);
|
||||
}
|
||||
}
|
||||
const text = await response.text();
|
||||
const err = new Error(`HTTP 429: ${text}`);
|
||||
(err as any).status = 429;
|
||||
(err as any).retry_after = retryAfterSeconds;
|
||||
const text = response.text || '';
|
||||
const err = new Error(`HTTP 429: ${text}`) as APIError;
|
||||
err.status = 429;
|
||||
err.retry_after = retryAfterSeconds;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => 'Unknown error');
|
||||
const isOk = response.status >= 200 && response.status < 300;
|
||||
if (!isOk) {
|
||||
const text = response.text || 'Unknown error';
|
||||
throw new Error(`HTTP ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
|
|
@ -201,12 +212,12 @@ export class RustShareAPI {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type') ?? '';
|
||||
if (contentType.includes('application/json')) {
|
||||
return response.json();
|
||||
const contentTypeHeader = headersLower['content-type'] ?? '';
|
||||
if (contentTypeHeader.includes('application/json')) {
|
||||
return response.json as unknown;
|
||||
}
|
||||
|
||||
return response.arrayBuffer();
|
||||
return response.arrayBuffer as unknown;
|
||||
}
|
||||
|
||||
// Device pairing methods
|
||||
|
|
|
|||
48
src/main.ts
48
src/main.ts
|
|
@ -1,8 +1,8 @@
|
|||
// Obsidian is a trademark of Dynalist Inc. RustShare is not affiliated with, endorsed by, or sponsored by Obsidian.
|
||||
// This file is part of RustShare Vault Sync.
|
||||
|
||||
import { Plugin, Notice, TFile } from 'obsidian';
|
||||
import { RustShareVaultSyncSettings, DEFAULT_SETTINGS, validateSettings } from './settings';
|
||||
import { Plugin, Notice, TFile, Platform, FileSystemAdapter } from 'obsidian';
|
||||
import { RustShareVaultSyncSettings, DEFAULT_SETTINGS } from './settings';
|
||||
import { RustShareVaultSyncSettingTab } from './ui/settings-tab';
|
||||
import { StatusBar } from './ui/status-bar';
|
||||
import { RustShareAPI } from './api';
|
||||
|
|
@ -10,7 +10,7 @@ import { SyncEngine } from './sync';
|
|||
import { SyncState, createEmptySyncState, migrateSyncState, pruneTombstones } from './state';
|
||||
import { SyncQueue, SyncOperation } from './sync-queue';
|
||||
import { syncLog } from './sync-log';
|
||||
import { generateDeviceId, detectCloudSyncFolder, shouldIgnorePath } from './utils';
|
||||
import { detectCloudSyncFolder, shouldIgnorePath } from './utils';
|
||||
|
||||
export default class RustShareVaultSyncPlugin extends Plugin {
|
||||
declare settings: RustShareVaultSyncSettings;
|
||||
|
|
@ -25,7 +25,19 @@ export default class RustShareVaultSyncPlugin extends Plugin {
|
|||
|
||||
// Generate device name if not set
|
||||
if (!this.settings.deviceName) {
|
||||
this.settings.deviceName = `${this.app.vault.getName()} - ${navigator.platform}`;
|
||||
let osName = 'Unknown OS';
|
||||
if (Platform.isMacOS) {
|
||||
osName = 'macOS';
|
||||
} else if (Platform.isWin) {
|
||||
osName = 'Windows';
|
||||
} else if (Platform.isLinux) {
|
||||
osName = 'Linux';
|
||||
} else if (Platform.isIosApp) {
|
||||
osName = 'iOS';
|
||||
} else if (Platform.isAndroidApp) {
|
||||
osName = 'Android';
|
||||
}
|
||||
this.settings.deviceName = `${this.app.vault.getName()} - ${osName}`;
|
||||
await this.saveSettings();
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +68,7 @@ export default class RustShareVaultSyncPlugin extends Plugin {
|
|||
|
||||
// Ribbon icon
|
||||
this.addRibbonIcon('cloud', 'RustShare Vault Sync', (evt: MouseEvent) => {
|
||||
this.runManualSync();
|
||||
void this.runManualSync();
|
||||
});
|
||||
|
||||
// Commands
|
||||
|
|
@ -120,7 +132,10 @@ export default class RustShareVaultSyncPlugin extends Plugin {
|
|||
});
|
||||
|
||||
// Check for double-sync warning
|
||||
const vaultPath = (this.app.vault.adapter as any).getBasePath?.() || '';
|
||||
let vaultPath = '';
|
||||
if (this.app.vault.adapter instanceof FileSystemAdapter) {
|
||||
vaultPath = this.app.vault.adapter.getBasePath();
|
||||
}
|
||||
const cloudFolder = detectCloudSyncFolder(vaultPath);
|
||||
if (cloudFolder) {
|
||||
new Notice(
|
||||
|
|
@ -136,10 +151,10 @@ export default class RustShareVaultSyncPlugin extends Plugin {
|
|||
}
|
||||
|
||||
async loadSettings() {
|
||||
const data = await this.loadData();
|
||||
const data = (await this.loadData()) as Record<string, unknown> | null;
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, data);
|
||||
if (data?.syncState) {
|
||||
this.syncState = migrateSyncState(data.syncState);
|
||||
if (data && data.syncState && typeof data.syncState === 'object') {
|
||||
this.syncState = migrateSyncState(data.syncState as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -213,9 +228,10 @@ export default class RustShareVaultSyncPlugin extends Plugin {
|
|||
deviceId = resp.id;
|
||||
this.settings.deviceId = deviceId;
|
||||
await this.saveSettings();
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
const isNetworkError = e instanceof TypeError ||
|
||||
/fetch|network|Failed to fetch/i.test(e?.message || '');
|
||||
/fetch|network|Failed to fetch/i.test(message);
|
||||
|
||||
if (this.settings.deviceId) {
|
||||
deviceId = this.settings.deviceId;
|
||||
|
|
@ -225,7 +241,7 @@ export default class RustShareVaultSyncPlugin extends Plugin {
|
|||
console.warn('Using cached device ID, registration failed:', e);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Failed to register device: ${e.message || e}`);
|
||||
throw new Error(`Failed to register device: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -282,7 +298,7 @@ export default class RustShareVaultSyncPlugin extends Plugin {
|
|||
}
|
||||
|
||||
const api = new RustShareAPI(this.settings.rustshareUrl, this.settings.authToken);
|
||||
const engine = new SyncEngine(this.app.vault, api, this.syncState, this.settings.deviceName);
|
||||
const engine = new SyncEngine(this.app, api, this.syncState, this.settings.deviceName);
|
||||
|
||||
try {
|
||||
this.statusBar.updateStatus('syncing', 'Syncing...');
|
||||
|
|
@ -321,7 +337,7 @@ export default class RustShareVaultSyncPlugin extends Plugin {
|
|||
this.isSyncing = true;
|
||||
try {
|
||||
const api = new RustShareAPI(this.settings.rustshareUrl, this.settings.authToken);
|
||||
const engine = new SyncEngine(this.app.vault, api, this.syncState, this.settings.deviceName);
|
||||
const engine = new SyncEngine(this.app, api, this.syncState, this.settings.deviceName);
|
||||
|
||||
this.statusBar.updateStatus('syncing', 'Syncing...');
|
||||
const result = await engine.syncIncremental(ops);
|
||||
|
|
@ -355,7 +371,9 @@ export default class RustShareVaultSyncPlugin extends Plugin {
|
|||
private startAutoSync(): void {
|
||||
if (this.syncInterval) return;
|
||||
const ms = this.settings.autoSyncIntervalMinutes * 60 * 1000;
|
||||
this.syncInterval = window.setInterval(() => this.runManualSync(), ms);
|
||||
this.syncInterval = window.setInterval(() => {
|
||||
void this.runManualSync();
|
||||
}, ms);
|
||||
}
|
||||
|
||||
private stopAutoSync(): void {
|
||||
|
|
|
|||
22
src/state.ts
22
src/state.ts
|
|
@ -34,22 +34,26 @@ export function createEmptySyncState(vaultId: string, deviceId: string, deviceNa
|
|||
};
|
||||
}
|
||||
|
||||
export function migrateSyncState(state: Partial<SyncState> & Record<string, any>): SyncState {
|
||||
const version = state.version ?? 0;
|
||||
export function migrateSyncState(state: Record<string, unknown>): SyncState {
|
||||
const version = typeof state.version === 'number' ? state.version : 0;
|
||||
if (version === 1) {
|
||||
// Current version, return as-is with defaults
|
||||
return {
|
||||
version: 1,
|
||||
vault_id: state.vault_id || '',
|
||||
device_id: state.device_id || '',
|
||||
device_name: state.device_name || '',
|
||||
last_server_rev: state.last_server_rev ?? 0,
|
||||
files: state.files || {},
|
||||
tombstones: state.tombstones || {},
|
||||
vault_id: typeof state.vault_id === 'string' ? state.vault_id : '',
|
||||
device_id: typeof state.device_id === 'string' ? state.device_id : '',
|
||||
device_name: typeof state.device_name === 'string' ? state.device_name : '',
|
||||
last_server_rev: typeof state.last_server_rev === 'number' ? state.last_server_rev : 0,
|
||||
files: (state.files && typeof state.files === 'object') ? (state.files as Record<string, LocalFileState>) : {},
|
||||
tombstones: (state.tombstones && typeof state.tombstones === 'object') ? (state.tombstones as Record<string, TombstoneState>) : {},
|
||||
};
|
||||
}
|
||||
// Future migrations go here
|
||||
return createEmptySyncState(state.vault_id || '', state.device_id || '', state.device_name || '');
|
||||
return createEmptySyncState(
|
||||
typeof state.vault_id === 'string' ? state.vault_id : '',
|
||||
typeof state.device_id === 'string' ? state.device_id : '',
|
||||
typeof state.device_name === 'string' ? state.device_name : ''
|
||||
);
|
||||
}
|
||||
|
||||
const TOMBSTONE_RETENTION_DAYS = 30;
|
||||
|
|
|
|||
|
|
@ -113,12 +113,13 @@ export class SyncQueue {
|
|||
try {
|
||||
await this.onSync(ops);
|
||||
this.retryCount = 0;
|
||||
} catch (error: any) {
|
||||
if (error?.status === 409) {
|
||||
} catch (error) {
|
||||
const apiError = error as { status?: number; retry_after?: number } | null;
|
||||
if (apiError?.status === 409) {
|
||||
throw error;
|
||||
}
|
||||
if (error?.status === 429 && typeof error?.retry_after === 'number' && !Number.isNaN(error.retry_after)) {
|
||||
this.lastRetryAfterMs = error.retry_after * 1000;
|
||||
if (apiError?.status === 429 && typeof apiError.retry_after === 'number' && !Number.isNaN(apiError.retry_after)) {
|
||||
this.lastRetryAfterMs = apiError.retry_after * 1000;
|
||||
}
|
||||
if (this.isRetryableError(error)) {
|
||||
this.online = false;
|
||||
|
|
@ -192,7 +193,9 @@ export class SyncQueue {
|
|||
return true;
|
||||
}
|
||||
// 429 Too Many Requests is a retryable network condition
|
||||
if ((error as any).status === 429) {
|
||||
// 429 Too Many Requests is a retryable network condition
|
||||
const status = (error as { status?: number }).status;
|
||||
if (status === 429) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -201,11 +204,12 @@ export class SyncQueue {
|
|||
|
||||
private isRetryableError(error: unknown): boolean {
|
||||
if (this.isNetworkError(error)) return true;
|
||||
const status = (error as any)?.status;
|
||||
const apiError = error as { status?: number; message?: string } | null;
|
||||
const status = apiError?.status;
|
||||
if (status === 429) return true;
|
||||
if (typeof status === 'number' && status >= 500 && status < 600) return true;
|
||||
// Also check message prefix for HTTP status codes
|
||||
const msg = (error as any)?.message || '';
|
||||
const msg = apiError?.message || '';
|
||||
if (/^HTTP 429:/.test(msg)) return true;
|
||||
if (/^HTTP 5\d\d:/.test(msg)) return true;
|
||||
return false;
|
||||
|
|
|
|||
40
src/sync.ts
40
src/sync.ts
|
|
@ -1,11 +1,11 @@
|
|||
// Obsidian is a trademark of Dynalist Inc. RustShare is not affiliated with, endorsed by, or sponsored by Obsidian.
|
||||
// This file is part of RustShare Vault Sync.
|
||||
|
||||
import { Vault, TFile, normalizePath } from 'obsidian';
|
||||
import { Vault, TFile, normalizePath, App } from 'obsidian';
|
||||
import { RustShareAPI, VaultManifestEntry } from './api';
|
||||
import { SyncState } from './state';
|
||||
import { sha256ArrayBuffer, formatConflictFileName, shouldIgnorePath } from './utils';
|
||||
import { SyncOperation, SyncOperationType } from './sync-queue';
|
||||
import { SyncOperation } from './sync-queue';
|
||||
import { syncLog } from './sync-log';
|
||||
|
||||
export interface SyncResult {
|
||||
|
|
@ -26,13 +26,15 @@ class UploadConflictError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export class SyncEngine {
|
||||
private vault: Vault;
|
||||
constructor(
|
||||
private vault: Vault,
|
||||
private app: App,
|
||||
private api: RustShareAPI,
|
||||
private state: SyncState,
|
||||
private deviceName: string,
|
||||
) {}
|
||||
) {
|
||||
this.vault = app.vault;
|
||||
}
|
||||
|
||||
async sync(): Promise<SyncResult> {
|
||||
// 1. Scan local files
|
||||
|
|
@ -94,8 +96,7 @@ export class SyncEngine {
|
|||
if (!remote) {
|
||||
// New file — upload
|
||||
try {
|
||||
const resp = await this.uploadFile(path, 0);
|
||||
// resp.server_rev is authoritative
|
||||
await this.uploadFile(path, 0);
|
||||
result.uploaded++;
|
||||
} catch (e) {
|
||||
if (e instanceof UploadConflictError) {
|
||||
|
|
@ -153,8 +154,7 @@ export class SyncEngine {
|
|||
} else if (remote.sha256 === localState.sha256) {
|
||||
// Local changed, remote unchanged — upload
|
||||
try {
|
||||
const resp = await this.uploadFile(path, remote.server_rev);
|
||||
// resp.server_rev is authoritative
|
||||
await this.uploadFile(path, remote.server_rev);
|
||||
result.uploaded++;
|
||||
} catch (e) {
|
||||
if (e instanceof UploadConflictError) {
|
||||
|
|
@ -331,11 +331,12 @@ export class SyncEngine {
|
|||
last_synced_at: new Date().toISOString(),
|
||||
};
|
||||
result.uploaded++;
|
||||
} catch (e: any) {
|
||||
if (e.error === 'conflict') {
|
||||
const serverSha256 = e.server_sha256;
|
||||
} catch (e) {
|
||||
const apiError = e as { error?: string; server_sha256?: string; current_rev?: number } | null;
|
||||
if (apiError && apiError.error === 'conflict') {
|
||||
const serverSha256 = apiError.server_sha256;
|
||||
try {
|
||||
await this.handleUploadConflict(op.path, e.current_rev ?? remote.server_rev, serverSha256);
|
||||
await this.handleUploadConflict(op.path, apiError.current_rev ?? remote.server_rev, serverSha256);
|
||||
result.conflicts++;
|
||||
} catch (conflictErr) {
|
||||
result.errors.push(`Conflict resolution failed: ${op.path}: ${conflictErr}`);
|
||||
|
|
@ -383,7 +384,7 @@ export class SyncEngine {
|
|||
const batch = allFiles.slice(i, i + batchSize);
|
||||
await Promise.all(batch.map(async (file) => {
|
||||
const path = file.path;
|
||||
if (shouldIgnorePath(path)) return;
|
||||
if (shouldIgnorePath(path, this.vault.configDir)) return;
|
||||
if (file instanceof TFile) {
|
||||
const buffer = await this.vault.readBinary(file);
|
||||
const hash = await sha256ArrayBuffer(buffer);
|
||||
|
|
@ -391,7 +392,7 @@ export class SyncEngine {
|
|||
}
|
||||
}));
|
||||
// Yield to event loop
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await new Promise(resolve => window.setTimeout(resolve, 0));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
|
@ -406,9 +407,10 @@ export class SyncEngine {
|
|||
let resp: { server_rev: number };
|
||||
try {
|
||||
resp = await this.api.uploadFile(this.state.vault_id, path, buffer, hash, baseServerRev, this.state.device_id);
|
||||
} catch (e: any) {
|
||||
if (e.error === 'conflict') {
|
||||
throw new UploadConflictError(path, e.server_sha256, e.current_rev);
|
||||
} catch (e) {
|
||||
const apiError = e as { error?: string; server_sha256?: string; current_rev?: number } | null;
|
||||
if (apiError && apiError.error === 'conflict') {
|
||||
throw new UploadConflictError(path, apiError.server_sha256, apiError.current_rev);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
|
@ -449,7 +451,7 @@ export class SyncEngine {
|
|||
private async deleteLocalFile(path: string, remoteServerRev?: number): Promise<void> {
|
||||
const file = this.vault.getAbstractFileByPath(normalizePath(path));
|
||||
if (!(file instanceof TFile)) return;
|
||||
await this.vault.delete(file);
|
||||
await this.app.fileManager.trashFile(file);
|
||||
delete this.state.files[path];
|
||||
if (remoteServerRev !== undefined) {
|
||||
this.state.tombstones[path] = { deleted_at: new Date().toISOString(), server_rev: remoteServerRev };
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ export class RustShareVaultSyncSettingTab extends PluginSettingTab {
|
|||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
containerEl.createEl('h2', { text: 'RustShare Vault Sync Settings' });
|
||||
new Setting(containerEl)
|
||||
.setName('RustShare Vault Sync Settings')
|
||||
.setHeading();
|
||||
|
||||
// Disclaimer
|
||||
containerEl.createEl('p', {
|
||||
|
|
@ -71,7 +73,9 @@ export class RustShareVaultSyncSettingTab extends PluginSettingTab {
|
|||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
containerEl.createEl('h3', { text: 'Advanced Sync Settings' });
|
||||
new Setting(containerEl)
|
||||
.setName('Advanced Sync Settings')
|
||||
.setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Save debounce (ms)')
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Obsidian is a trademark of Dynalist Inc. RustShare is not affiliated with, endorsed by, or sponsored by Obsidian.
|
||||
// This file is part of RustShare Vault Sync.
|
||||
|
||||
import { Plugin, setIcon } from 'obsidian';
|
||||
import { Plugin } from 'obsidian';
|
||||
|
||||
type SyncStatus = 'disconnected' | 'connected' | 'syncing' | 'synced' | 'conflict' | 'error' | 'offline';
|
||||
|
||||
|
|
@ -19,8 +19,8 @@ export class StatusBar {
|
|||
this.currentStatus = status;
|
||||
this.statusBarItemEl.empty();
|
||||
|
||||
const iconEl = this.statusBarItemEl.createEl('span', { cls: 'rustshare-sync-status-icon' });
|
||||
const textEl = this.statusBarItemEl.createEl('span', { text: message || this.statusText(status) });
|
||||
this.statusBarItemEl.createEl('span', { cls: 'rustshare-sync-status-icon' });
|
||||
this.statusBarItemEl.createEl('span', { text: message || this.statusText(status) });
|
||||
|
||||
this.statusBarItemEl.removeClass(
|
||||
'rustshare-sync-status--disconnected',
|
||||
|
|
|
|||
22
src/utils.ts
22
src/utils.ts
|
|
@ -32,17 +32,17 @@ export function formatConflictFileName(originalPath: string, deviceName: string,
|
|||
return `${dir}${name} (RustShare conflicted copy ${safeDeviceName} ${timestamp})${ext}`;
|
||||
}
|
||||
|
||||
export const DEFAULT_IGNORED_PATHS = [
|
||||
'.obsidian/',
|
||||
'.git/',
|
||||
'.DS_Store',
|
||||
'node_modules/',
|
||||
'Thumbs.db',
|
||||
'.rustshare-sync-state.json',
|
||||
];
|
||||
|
||||
export function shouldIgnorePath(path: string): boolean {
|
||||
for (const ignored of DEFAULT_IGNORED_PATHS) {
|
||||
export function shouldIgnorePath(path: string, configDir = '.obsidian'): boolean {
|
||||
const normalizedConfigDir = configDir.endsWith('/') ? configDir : configDir + '/';
|
||||
const ignoredPaths = [
|
||||
normalizedConfigDir,
|
||||
'.git/',
|
||||
'.DS_Store',
|
||||
'node_modules/',
|
||||
'Thumbs.db',
|
||||
'.rustshare-sync-state.json',
|
||||
];
|
||||
for (const ignored of ignoredPaths) {
|
||||
if (ignored.endsWith('/')) {
|
||||
// Directory patterns: match at start or after a slash
|
||||
if (path.startsWith(ignored) || path.includes('/' + ignored)) return true;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export function normalizePath(path: string): string {
|
|||
}
|
||||
|
||||
export class Vault {
|
||||
configDir = '.obsidian';
|
||||
private files = new Map<string, TAbstractFile>();
|
||||
private contents = new Map<string, ArrayBuffer>();
|
||||
|
||||
|
|
@ -90,8 +91,23 @@ export class Vault {
|
|||
}
|
||||
}
|
||||
|
||||
export class FileManager {
|
||||
constructor(private vault: Vault) {}
|
||||
async trashFile(file: TAbstractFile): Promise<void> {
|
||||
await this.vault.delete(file);
|
||||
}
|
||||
}
|
||||
|
||||
export class App {
|
||||
vault: Vault;
|
||||
fileManager: FileManager;
|
||||
constructor(vault: Vault) {
|
||||
this.vault = vault;
|
||||
this.fileManager = new FileManager(vault);
|
||||
}
|
||||
}
|
||||
|
||||
export class Plugin {}
|
||||
export class Notice {}
|
||||
export class App {}
|
||||
export class PluginSettingTab {}
|
||||
export class Setting {}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { Vault, TFile } from './mocks/obsidian';
|
||||
import { Vault, TFile, App } from './mocks/obsidian';
|
||||
import { SyncEngine } from '../src/sync';
|
||||
import { createEmptySyncState, SyncState } from '../src/state';
|
||||
import { VaultManifest, VaultManifestEntry } from '../src/api';
|
||||
|
|
@ -105,15 +105,17 @@ class MockAPI {
|
|||
|
||||
describe('SyncEngine', () => {
|
||||
let vault: Vault;
|
||||
let app: App;
|
||||
let api: MockAPI;
|
||||
let state: SyncState;
|
||||
let engine: SyncEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
vault = new Vault();
|
||||
app = new App(vault);
|
||||
api = new MockAPI();
|
||||
state = createEmptySyncState('test-vault', 'device-123', 'test-device');
|
||||
engine = new SyncEngine(vault, api as any, state, 'test-device');
|
||||
engine = new SyncEngine(app as any, api as any, state, 'test-device');
|
||||
});
|
||||
|
||||
describe('full sync', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue