diff --git a/main.ts b/main.ts index befde90..8fd7d26 100644 --- a/main.ts +++ b/main.ts @@ -211,30 +211,46 @@ export default class UltimateTodoistSyncForObsidian extends Plugin { this.debugLog('[Plugin] Auto-claimed as primary device: ' + this.cachedDeviceId); } - new Notice(`Ultimate Todoist Sync loaded successfully.`); this.logOperation?.log('PLUGIN_INITIALIZED', 'Plugin initialized successfully'); - this.runStartupDatabaseCheck(); + const startupDatabaseCheckSucceeded = await this.runStartupDatabaseCheck(); + if (startupDatabaseCheckSucceeded) { + new Notice(`Ultimate Todoist Sync loaded successfully.`); + } else { + new Notice(`Ultimate Todoist Sync loaded with startup sync warnings. Please check console logs.`, 8000); + } return true; } - async runStartupDatabaseCheck(): Promise { + async runStartupDatabaseCheck(): Promise { try { if (!this.databaseChecker) { this.debugLog('Database checker not initialized, skipping startup check'); - return; + return true; } this.debugLog('Running startup database check...'); const result = await this.databaseChecker.checkDatabase(); await this.safeSettings?.update({ lastDatabaseCheckTime: Date.now() }, true); + + const startupCheckFailed = result.issues.some((issue) => + issue.type === 'issue_unclassified' && issue.details.startsWith('Database check failed:') + ); + if (startupCheckFailed) { + new Notice('Startup database check failed to reach Todoist. Please verify network/API status and try sync again.', 10000); + return false; + } + if (result.success) { this.debugLog('Startup database check passed'); + return true; } else { new Notice(`Found ${result.totalIssues} database issue(s). Please use "Fix Database" in settings to resolve them.`); + return true; } } catch (error) { console.error('Startup database check failed:', error); + return false; } } @@ -265,9 +281,7 @@ export default class UltimateTodoistSyncForObsidian extends Plugin { if (!loaded) { await this.todoistSyncAPI?.initializeSync(); } else { - this.todoistSyncAPI?.incrementalSync().catch(err => { - console.error('[Plugin] Incremental sync after cache load failed:', err); - }); + this.debugLog('[Plugin] Loaded sync cache from settings; startup database check will refresh incrementally.'); } } catch (error) { console.error('[Plugin] Failed to initialize sync:', error); diff --git a/src/api/syncApi.ts b/src/api/syncApi.ts index e7dd17a..acd3770 100644 --- a/src/api/syncApi.ts +++ b/src/api/syncApi.ts @@ -20,6 +20,16 @@ type FilterOptions = { object_type?: string; }; +type ApiHttpError = Error & { + statusCode: number; + context: string; + retryAfterSeconds?: number; + isRetryable: boolean; + isAuthError: boolean; +}; + +type RequestUrlResponse = Awaited>; + export class TodoistSyncAPI { app:App; plugin: UltimateTodoistSyncForObsidian; @@ -27,13 +37,9 @@ export class TodoistSyncAPI { private readonly PARTIAL_SYNC_LIMIT = 1000; private readonly FULL_SYNC_LIMIT = 100; - private readonly WINDOW_DURATION = 15 * 60 * 1000; - - private rateLimitState = { - partialSyncCount: 0, - fullSyncCount: 0, - windowStartTime: Date.now() - }; + private readonly ROLLING_WINDOW_MINUTES = 15; + private readonly RATE_LIMIT_BUCKET_MS = 60 * 1000; + private rateLimitBuckets = new Map(); // Store complete raw API response // Store complete raw API response @@ -42,6 +48,12 @@ export class TodoistSyncAPI { private _syncRunning = false; private _syncDirty = false; private _syncWaiters: Array<{ resolve: () => void; reject: (err: any) => void }> = []; + private authFailureStatusCode: 401 | 403 | null = null; + private authFailureToken = ''; + private authFailureBlockedUntil = 0; + private lastAuthNoticeAt = 0; + private readonly AUTH_NOTICE_COOLDOWN_MS = 30 * 1000; + private readonly AUTH_FAILURE_BLOCK_MS = 60 * 1000; constructor(app:App, plugin:UltimateTodoistSyncForObsidian) { //super(app,settings); @@ -50,38 +62,100 @@ export class TodoistSyncAPI { this.deviceManager = new DeviceManager(app, plugin); } - private resetRateLimits(): void { - this.rateLimitState.partialSyncCount = 0; - this.rateLimitState.fullSyncCount = 0; - this.rateLimitState.windowStartTime = Date.now(); + private getCurrentMinuteEpoch(): number { + return Math.floor(Date.now() / this.RATE_LIMIT_BUCKET_MS); } - private checkRateLimitWindow(): void { - if (Date.now() - this.rateLimitState.windowStartTime > this.WINDOW_DURATION) { - this.resetRateLimits(); + private pruneRateLimitBuckets(nowMinute: number): void { + const minMinute = nowMinute - (this.ROLLING_WINDOW_MINUTES - 1); + for (const minute of this.rateLimitBuckets.keys()) { + if (minute < minMinute) { + this.rateLimitBuckets.delete(minute); + } } } - private async checkRateLimit(isFullSync: boolean): Promise { - this.checkRateLimitWindow(); + private getRateLimitUsage(nowMinute = this.getCurrentMinuteEpoch()): { partial: number; full: number } { + this.pruneRateLimitBuckets(nowMinute); + let partial = 0; + let full = 0; + for (const bucket of this.rateLimitBuckets.values()) { + partial += bucket.partial; + full += bucket.full; + } + return { partial, full }; + } + private incrementRateLimitUsage(isFullSync: boolean): void { + const nowMinute = this.getCurrentMinuteEpoch(); + this.pruneRateLimitBuckets(nowMinute); + const existing = this.rateLimitBuckets.get(nowMinute) ?? { partial: 0, full: 0 }; + if (isFullSync) { + existing.full++; + } else { + existing.partial++; + } + this.rateLimitBuckets.set(nowMinute, existing); + } + + private estimateWaitMinutes(isFullSync: boolean, nowMinute: number): number { const limit = isFullSync ? this.FULL_SYNC_LIMIT : this.PARTIAL_SYNC_LIMIT; - const used = isFullSync ? this.rateLimitState.fullSyncCount : this.rateLimitState.partialSyncCount; + const key = isFullSync ? 'full' : 'partial'; + const entries = Array.from(this.rateLimitBuckets.entries()) + .filter(([minute]) => minute >= nowMinute - (this.ROLLING_WINDOW_MINUTES - 1)) + .sort((a, b) => a[0] - b[0]); + + let total = entries.reduce((sum, [, bucket]) => sum + bucket[key], 0); + if (total < limit) return 0; + + for (const [minute, bucket] of entries) { + total -= bucket[key]; + const wait = Math.max(1, minute + this.ROLLING_WINDOW_MINUTES - nowMinute); + if (total < limit) return wait; + } + + return this.ROLLING_WINDOW_MINUTES; + } + + private async checkRateLimit(isFullSync: boolean): Promise { + const nowMinute = this.getCurrentMinuteEpoch(); + const usage = this.getRateLimitUsage(nowMinute); + const limit = isFullSync ? this.FULL_SYNC_LIMIT : this.PARTIAL_SYNC_LIMIT; + const used = isFullSync ? usage.full : usage.partial; if (used >= limit) { - const waitMinutes = Math.ceil((this.WINDOW_DURATION - (Date.now() - this.rateLimitState.windowStartTime)) / 60000); - throw new Error(`RATE_LIMIT_EXCEEDED:${waitMinutes}`); + const waitMinutes = this.estimateWaitMinutes(isFullSync, nowMinute); + const kind = isFullSync ? 'full' : 'partial'; + throw new Error(`RATE_LIMIT_EXCEEDED:${kind}:${waitMinutes}`); } } private handleRateLimitError(error: Error): void { const message = error.message; + if (this.isApiHttpError(error) && error.statusCode === 429) { + const retryAfter = error.retryAfterSeconds ?? 15 * 60; + const waitMinutes = Math.max(1, Math.ceil(retryAfter / 60)); + new Notice( + `⚠️ Too Many Requests to Todoist!\n\n` + + `Please wait about ${waitMinutes} minutes before next sync.\n\n` + + `Tip: Reduce sync frequency to avoid hitting rate limits.`, + 15000 + ); + return; + } if (message.includes('RATE_LIMIT_EXCEEDED')) { - const waitMinutes = message.split(':')[1] || '15'; + const parts = message.split(':'); + const limitTypeFromError = parts.length >= 3 ? parts[1] : undefined; + const waitMinutes = parts.length >= 3 ? parts[2] : (parts[1] || '15'); + const usage = this.getRateLimitUsage(); + const derivedType = usage.full >= this.FULL_SYNC_LIMIT ? 'full' : 'partial'; + const limitType = (limitTypeFromError === 'full' || limitTypeFromError === 'partial') + ? limitTypeFromError + : derivedType; new Notice( `⚠️ Todoist API Rate Limit Reached!\n\n` + - `You have reached the ${this.rateLimitState.fullSyncCount >= this.FULL_SYNC_LIMIT ? 'full' : 'partial'} sync limit.\n` + + `You have reached the ${limitType} sync limit.\n` + `Please wait ${waitMinutes} minutes before next sync.\n\n` + `Tip: Reduce sync frequency to avoid hitting limits.`, 15000 @@ -96,6 +170,147 @@ export class TodoistSyncAPI { } } + private isApiHttpError(error: unknown): error is ApiHttpError { + if (!(error instanceof Error)) return false; + const candidate = error as Partial; + return typeof candidate.statusCode === 'number' && typeof candidate.context === 'string'; + } + + private normalizeRequestUrlThrownHttpError(context: string, error: unknown): ApiHttpError | null { + if (!error || typeof error !== 'object') return null; + const candidate = error as { + status?: unknown; + statusCode?: unknown; + headers?: unknown; + }; + + const statusCode = typeof candidate.status === 'number' + ? candidate.status + : (typeof candidate.statusCode === 'number' ? candidate.statusCode : null); + if (statusCode === null) return null; + + let retryAfterSeconds: number | undefined; + if (statusCode === 429 && candidate.headers && typeof candidate.headers === 'object') { + const headerRecord = candidate.headers as Record; + const retryAfterRaw = headerRecord['retry-after'] ?? headerRecord['Retry-After']; + if (typeof retryAfterRaw === 'string') { + const parsed = Number.parseInt(retryAfterRaw, 10); + if (Number.isFinite(parsed) && parsed > 0) { + retryAfterSeconds = parsed; + } + } + } + + return Object.assign(new Error(`${context} failed: HTTP ${statusCode}`), { + statusCode, + context, + retryAfterSeconds, + isRetryable: statusCode === 429 || statusCode >= 500, + isAuthError: statusCode === 401 || statusCode === 403, + }) as ApiHttpError; + } + + private getRetryAfterSeconds(response: RequestUrlResponse): number | undefined { + const body = response.json as { error_extra?: { retry_after?: unknown } } | undefined; + const retryAfterFromBody = body?.error_extra?.retry_after; + if (typeof retryAfterFromBody === 'number' && Number.isFinite(retryAfterFromBody) && retryAfterFromBody > 0) { + return retryAfterFromBody; + } + + const headers = response.headers as unknown; + if (headers && typeof headers === 'object') { + const headerRecord = headers as Record; + const raw = headerRecord['retry-after'] ?? headerRecord['Retry-After']; + if (typeof raw === 'string') { + const parsed = Number.parseInt(raw, 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + } + + return undefined; + } + + private createApiHttpError(context: string, response: RequestUrlResponse): ApiHttpError { + const statusCode = response.status; + const retryAfterSeconds = statusCode === 429 ? this.getRetryAfterSeconds(response) : undefined; + let message = `${context} failed: HTTP ${statusCode}`; + if (statusCode === 400) message = `${context} failed: HTTP 400 Bad Request`; + if (statusCode === 401) message = `${context} failed: HTTP 401 Unauthorized`; + if (statusCode === 403) message = `${context} failed: HTTP 403 Forbidden`; + if (statusCode === 404) message = `${context} failed: HTTP 404 Not Found`; + if (statusCode === 429) { + message = `${context} failed: HTTP 429 Too Many Requests${retryAfterSeconds ? ` (retry_after=${retryAfterSeconds}s)` : ''}`; + } + + return Object.assign(new Error(message), { + statusCode, + context, + retryAfterSeconds, + isRetryable: statusCode === 429 || statusCode >= 500, + isAuthError: statusCode === 401 || statusCode === 403, + }); + } + + private maybeNotifyAuthFailure(statusCode: 401 | 403): void { + const now = Date.now(); + if (now - this.lastAuthNoticeAt < this.AUTH_NOTICE_COOLDOWN_MS) return; + this.lastAuthNoticeAt = now; + + if (statusCode === 401) { + new Notice('Todoist authentication failed (401). Please verify or refresh your API token.', 10000); + return; + } + + new Notice('Todoist access forbidden (403). Token or permission may be invalid. Please re-check Todoist authorization.', 10000); + } + + private registerAuthFailure(statusCode: 401 | 403): void { + this.authFailureStatusCode = statusCode; + this.authFailureToken = this.plugin.settings.todoistAPIToken || ''; + this.authFailureBlockedUntil = Date.now() + this.AUTH_FAILURE_BLOCK_MS; + this.maybeNotifyAuthFailure(statusCode); + } + + private clearAuthFailureIfTokenChanged(): void { + const currentToken = this.plugin.settings.todoistAPIToken || ''; + if (this.authFailureStatusCode !== null && this.authFailureToken !== currentToken) { + this.authFailureStatusCode = null; + this.authFailureToken = ''; + this.authFailureBlockedUntil = 0; + } + } + + private ensureAuthFailureNotBlocked(context: string): void { + this.clearAuthFailureIfTokenChanged(); + if (this.authFailureStatusCode === null) return; + if (Date.now() >= this.authFailureBlockedUntil) { + this.authFailureStatusCode = null; + this.authFailureToken = ''; + this.authFailureBlockedUntil = 0; + return; + } + + const statusCode = this.authFailureStatusCode; + const message = `${context} aborted: previous Todoist auth failure (${statusCode}). Update token/permissions before retrying.`; + const blockedError = Object.assign(new Error(message), { + statusCode, + context, + isRetryable: false, + isAuthError: true, + }) as ApiHttpError; + throw blockedError; + } + + private assertSuccessfulResponse(context: string, response: RequestUrlResponse): void { + if (response.status >= 200 && response.status < 300) return; + + const httpError = this.createApiHttpError(context, response); + if (httpError.statusCode === 401 || httpError.statusCode === 403) { + this.registerAuthFailure(httpError.statusCode); + } + throw httpError; + } + private async getClientHeader(): Promise { return await this.deviceManager.getClientHeader(); } @@ -162,6 +377,7 @@ export class TodoistSyncAPI { } catch (error) { console.error('[TodoistSyncAPI] Incremental sync failed:', error); waiters.forEach(w => w.reject(error)); + throw error; } finally { this._syncRunning = false; } @@ -219,8 +435,9 @@ export class TodoistSyncAPI { } async getAllResources(fullSync = false) { + this.ensureAuthFailureNotBlocked('getAllResources'); const clientId = await this.getClientHeader(); - const accessToken = this.plugin.settings.todoistAPIToken; + const accessToken = this.plugin.settings.todoistAPIToken; const syncToken = fullSync ? '*' : (this.syncData?.sync_token || '*'); await this.checkRateLimit(fullSync); @@ -240,37 +457,56 @@ export class TodoistSyncAPI { }).toString() }; - try { - const response = await requestUrl(options); - - if (response.status === 429) { - throw new Error('RATE_LIMIT_429'); - } + try { + const response = await requestUrl(options); + this.assertSuccessfulResponse('getAllResources', response); - if (response.status >= 400) { - throw new Error(`Failed to fetch all resources: ${response.status} ${response.text}`); - } - - const data = response.json; + const data = response.json; - if (fullSync) { - this.rateLimitState.fullSyncCount++; - } else { - this.rateLimitState.partialSyncCount++; - } + this.incrementRateLimitUsage(fullSync); return data; - } catch (error: any) { - if (error.message && (error.message.includes('RATE_LIMIT') || error.message.includes('429'))) { - this.handleRateLimitError(error); + } catch (error) { + if (this.isApiHttpError(error)) { + if (error.statusCode === 401 || error.statusCode === 403) { + this.registerAuthFailure(error.statusCode); + } + if (error.statusCode === 429) { + this.handleRateLimitError(error); + } + console.error(error); + throw error; } - console.error(error); - throw new Error('Failed to fetch all resources due to network error'); - } + + const normalizedHttpError = this.normalizeRequestUrlThrownHttpError('getAllResources', error); + if (normalizedHttpError) { + if (normalizedHttpError.statusCode === 401 || normalizedHttpError.statusCode === 403) { + this.registerAuthFailure(normalizedHttpError.statusCode); + } + if (normalizedHttpError.statusCode === 429) { + this.handleRateLimitError(normalizedHttpError); + } + console.error(error); + throw normalizedHttpError; + } + + if (error instanceof Error && (error.message.includes('RATE_LIMIT') || error.message.includes('429'))) { + this.handleRateLimitError(error); + console.error(error); + throw error; + } + + console.error(error); + if (error instanceof Error) { + throw error; + } + throw new Error('Failed to fetch all resources due to network error'); + } } //backup todoist async getUserResource() { + this.ensureAuthFailureNotBlocked('getUserResource'); const accessToken = this.plugin.settings.todoistAPIToken const url = 'https://api.todoist.com/api/v1/sync'; const options = { @@ -288,15 +524,15 @@ export class TodoistSyncAPI { try { const response = await requestUrl(options); - - if (response.status >= 400) { - throw new Error(`Failed to fetch all resources: ${response.status} ${response.text}`); - } + this.assertSuccessfulResponse('getUserResource', response); const data = response.json; this.plugin.debugLog(data) return data; } catch (error) { + if (this.isApiHttpError(error)) { + throw error; + } console.error(error) throw new Error('Failed to fetch user resources due to network error'); } @@ -305,6 +541,7 @@ export class TodoistSyncAPI { //update user timezone async updateUserTimezone() { + this.ensureAuthFailureNotBlocked('updateUserTimezone'); const unixTimestampString: string = Math.floor(Date.now() / 1000).toString(); const accessToken = this.plugin.settings.todoistAPIToken const url = 'https://api.todoist.com/api/v1/sync'; @@ -327,15 +564,15 @@ export class TodoistSyncAPI { try { const response = await requestUrl(options); - - if (response.status >= 400) { - throw new Error(`Failed to fetch all resources: ${response.status} ${response.text}`); - } + this.assertSuccessfulResponse('updateUserTimezone', response); const data = response.json; this.plugin.debugLog(data) return data; } catch (error) { + if (this.isApiHttpError(error)) { + throw error; + } console.error('[updateUserTimezone] Failed:', error); throw new Error('Failed to fetch user resources due to network error'); } @@ -344,7 +581,8 @@ export class TodoistSyncAPI { //get activity logs //result {results:[],next_cursor:null} async getAllActivityEvents() { - const clientId = await this.getClientHeader(); + this.ensureAuthFailureNotBlocked('getAllActivityEvents'); + const clientId = await this.getClientHeader(); const accessToken = this.plugin.settings.todoistAPIToken try { @@ -357,9 +595,7 @@ export class TodoistSyncAPI { } }); - if (response.status >= 400) { - throw new Error(`API returned error status: ${response.status}`); - } + this.assertSuccessfulResponse('getAllActivityEvents', response); const data = response.json; @@ -367,6 +603,9 @@ export class TodoistSyncAPI { // 转换为旧格式: { events: [] } return { events: data.results || [] }; } catch (error) { + if (this.isApiHttpError(error)) { + throw error; + } throw error; } } @@ -388,6 +627,7 @@ export class TodoistSyncAPI { //get completed items activity //result {results:[],next_cursor:null} async getCompletedItemsActivity() { + this.ensureAuthFailureNotBlocked('getCompletedItemsActivity'); const accessToken = this.plugin.settings.todoistAPIToken const url = 'https://api.todoist.com/api/v1/activities?event_type=completed'; @@ -400,9 +640,7 @@ export class TodoistSyncAPI { } }); - if (response.status >= 400) { - throw new Error(`Failed to fetch completed items: ${response.status} ${response.text}`); - } + this.assertSuccessfulResponse('getCompletedItemsActivity', response); const data = response.json; @@ -410,6 +648,9 @@ export class TodoistSyncAPI { // 转换为旧格式: { events: [] } return { events: data.results || [] }; } catch (error) { + if (this.isApiHttpError(error)) { + throw error; + } console.error(error); throw new Error('Failed to fetch completed items due to network error'); } @@ -420,6 +661,7 @@ export class TodoistSyncAPI { //get uncompleted items activity //result {results:[],next_cursor:null} async getUncompletedItemsActivity() { + this.ensureAuthFailureNotBlocked('getUncompletedItemsActivity'); const accessToken = this.plugin.settings.todoistAPIToken const url = 'https://api.todoist.com/api/v1/activities?event_type=uncompleted'; @@ -432,9 +674,7 @@ export class TodoistSyncAPI { } }); - if (response.status >= 400) { - throw new Error(`Failed to fetch uncompleted items: ${response.status} ${response.text}`); - } + this.assertSuccessfulResponse('getUncompletedItemsActivity', response); const data = response.json; @@ -442,6 +682,9 @@ export class TodoistSyncAPI { // 转换为旧格式: { events: [] } return { events: data.results || [] }; } catch (error) { + if (this.isApiHttpError(error)) { + throw error; + } console.error(error); throw new Error('Failed to fetch uncompleted items due to network error'); } @@ -457,6 +700,7 @@ export class TodoistSyncAPI { //get updated items activity //result {results:[],next_cursor:null} async getUpdatedItemsActivity() { + this.ensureAuthFailureNotBlocked('getUpdatedItemsActivity'); const accessToken = this.plugin.settings.todoistAPIToken const url = 'https://api.todoist.com/api/v1/activities?event_type=updated'; @@ -469,9 +713,7 @@ export class TodoistSyncAPI { } }); - if (response.status >= 400) { - throw new Error(`Failed to fetch updated items: ${response.status} ${response.text}`); - } + this.assertSuccessfulResponse('getUpdatedItemsActivity', response); const data = response.json; @@ -479,6 +721,9 @@ export class TodoistSyncAPI { // 转换为旧格式: { events: [] } return { events: data.results || [] }; } catch (error) { + if (this.isApiHttpError(error)) { + throw error; + } console.error(error); throw new Error('Failed to fetch updated items due to network error'); } @@ -490,6 +735,7 @@ export class TodoistSyncAPI { //get projects activity //result {results:[],next_cursor:null} async getProjectsActivity() { + this.ensureAuthFailureNotBlocked('getProjectsActivity'); const accessToken = this.plugin.settings.todoistAPIToken const url = 'https://api.todoist.com/api/v1/activities?object_type=project'; @@ -502,9 +748,7 @@ export class TodoistSyncAPI { } }); - if (response.status >= 400) { - throw new Error(`Failed to fetch projects activities: ${response.status} ${response.text}`); - } + this.assertSuccessfulResponse('getProjectsActivity', response); const data = response.json; @@ -512,6 +756,9 @@ export class TodoistSyncAPI { // 转换为旧格式: { events: [] } return { events: data.results || [] }; } catch (error) { + if (this.isApiHttpError(error)) { + throw error; + } console.error(error); throw new Error('Failed to fetch projects activities due to network error'); } @@ -532,7 +779,8 @@ export class TodoistSyncAPI { // Execute Sync API commands async executeCommands(commands: any[]): Promise { - await this.checkRateLimit(false); + this.ensureAuthFailureNotBlocked('executeCommands'); + await this.checkRateLimit(false); const clientId = await this.getClientHeader(); const accessToken = this.plugin.settings.todoistAPIToken; @@ -553,14 +801,7 @@ export class TodoistSyncAPI { try { const response = await requestUrl(options); - - if (response.status === 429) { - throw new Error('RATE_LIMIT_429'); - } - - if (response.status >= 400) { - throw new Error(`Failed to execute commands: ${response.status} - ${response.text}`); - } + this.assertSuccessfulResponse('executeCommands', response); const data = response.json; if (data?.sync_status) { @@ -572,16 +813,22 @@ export class TodoistSyncAPI { } } - this.rateLimitState.partialSyncCount++; + this.incrementRateLimitUsage(false); return data; - } catch (error: any) { - if (error.message && (error.message.includes('RATE_LIMIT') || error.message.includes('429'))) { + } catch (error) { + if (this.isApiHttpError(error)) { + if (error.statusCode === 429) this.handleRateLimitError(error); + console.error('Error executing commands:', error); + throw error; + } + + if (error instanceof Error && (error.message.includes('RATE_LIMIT') || error.message.includes('429'))) { this.handleRateLimitError(error); } - console.error('Error executing commands:', error); - throw error; + console.error('Error executing commands:', error); + throw error; } } @@ -760,23 +1007,32 @@ export class TodoistSyncAPI { } // Compatible wrapper: GetTaskById - async GetTaskById(taskId: string): Promise { + async GetTaskById(taskId: string, options?: { allowNetworkRefresh?: boolean }): Promise { try { + const allowNetworkRefresh = options?.allowNetworkRefresh !== false; + if (!this.syncData) { + if (!allowNetworkRefresh) { + return undefined; + } this.syncData = await this.getAllResources(true); } const tasks = this.syncData?.items || []; const found = tasks.find((t: any) => t.id === taskId); if (found) return found; + if (!allowNetworkRefresh) { + return undefined; + } + // Not in local cache — do an incremental sync and retry once. // This handles the race where a task was just created and syncData // hasn't been updated yet (e.g. lineModifiedTaskCheck fires immediately // after lineContentNewTaskCheck). try { await this.incrementalSync(); - } catch (_) { - // ignore sync errors here; fall through to return undefined + } catch (error) { + console.warn(`[TodoistSyncAPI] Incremental refresh in GetTaskById failed for ${taskId}:`, error); } const refreshed = this.syncData?.items || []; return refreshed.find((t: any) => t.id === taskId); @@ -933,5 +1189,3 @@ export class TodoistSyncAPI { } } - - diff --git a/src/data/databaseChecker.ts b/src/data/databaseChecker.ts index b8d230a..b7ee4ad 100644 --- a/src/data/databaseChecker.ts +++ b/src/data/databaseChecker.ts @@ -213,18 +213,18 @@ export class DatabaseChecker { return labelsA.every((label, index) => label === labelsB[index]); } - private async confirmTodoistTaskMissing(taskId: string): Promise { - const todoistSyncAPI = this.plugin.todoistSyncAPI; - if (!todoistSyncAPI) return false; + private async confirmTodoistTaskMissing(taskId: string): Promise { + const todoistSyncAPI = this.plugin.todoistSyncAPI; + if (!todoistSyncAPI) return false; - try { - const task = await todoistSyncAPI.GetTaskById(taskId); - return !task; - } catch (error) { - console.error(`[DatabaseChecker] confirmTodoistTaskMissing failed for ${taskId}:`, error); - return false; - } - } + try { + const task = await todoistSyncAPI.GetTaskById(taskId, { allowNetworkRefresh: false }); + return !task; + } catch (error) { + console.error(`[DatabaseChecker] confirmTodoistTaskMissing failed for ${taskId}:`, error); + return false; + } + } /** * 主检查方法 - 执行完整的数据库一致性检查 @@ -296,6 +296,12 @@ export class DatabaseChecker { await todoistSyncAPI.initializeSync(); // 重新获取 syncData syncData = todoistSyncAPI.getSyncData(); + } else { + if (noticeCallback) { + noticeCallback('Step 2/4: Refreshing Todoist cache...'); + } + await todoistSyncAPI.incrementalSync(); + syncData = todoistSyncAPI.getSyncData(); } // 使用 fileOperation 的方法从 syncData 中获取 Todoist 任务 const todoistTasksMap = fileOperation.getTodoistTasksFromSyncData(syncData);