mirror of
https://github.com/heroblackink/ultimate-todoist-sync-for-obsidian.git
synced 2026-07-22 07:40:27 +00:00
fix(sync): harden startup sync flow and preserve HTTP errors
This commit is contained in:
parent
bde56a3570
commit
a053fa06e9
3 changed files with 379 additions and 105 deletions
28
main.ts
28
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<void> {
|
||||
async runStartupDatabaseCheck(): Promise<boolean> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof requestUrl>>;
|
||||
|
||||
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<number, { partial: number; full: number }>();
|
||||
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<ApiHttpError>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string> {
|
||||
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);
|
||||
try {
|
||||
const response = await requestUrl(options);
|
||||
this.assertSuccessfulResponse('getAllResources', response);
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new Error('RATE_LIMIT_429');
|
||||
}
|
||||
const data = response.json;
|
||||
|
||||
if (response.status >= 400) {
|
||||
throw new Error(`Failed to fetch all resources: ${response.status} ${response.text}`);
|
||||
}
|
||||
|
||||
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<any> {
|
||||
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<any> {
|
||||
async GetTaskById(taskId: string, options?: { allowNetworkRefresh?: boolean }): Promise<any> {
|
||||
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 {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -213,18 +213,18 @@ export class DatabaseChecker {
|
|||
return labelsA.every((label, index) => label === labelsB[index]);
|
||||
}
|
||||
|
||||
private async confirmTodoistTaskMissing(taskId: string): Promise<boolean> {
|
||||
const todoistSyncAPI = this.plugin.todoistSyncAPI;
|
||||
if (!todoistSyncAPI) return false;
|
||||
private async confirmTodoistTaskMissing(taskId: string): Promise<boolean> {
|
||||
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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue