diff --git a/entry/src/main/ets/common/DavClient.ets b/entry/src/main/ets/common/DavClient.ets index 786cfdb..ee3a03c 100644 --- a/entry/src/main/ets/common/DavClient.ets +++ b/entry/src/main/ets/common/DavClient.ets @@ -36,6 +36,20 @@ export class DavCalendarDiscovery { entries: DavCalendarEntry[] = []; } +/** + * 带 HTTP 状态码的 DAV 请求错误。 + * 同步引擎据此区分「404 日历本/资源在服务器上已不存在」(可降级处理: + * 单本跳过、自动从账号移除)与其它网络/服务器故障(整个账号同步失败)。 + */ +export class DavHttpError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + export class DavClient { /** * PROPFIND 拉取某路径下所有集合的 calendar-color @@ -346,7 +360,7 @@ export class DavClient { }); LogUtil.write(`HTTP REPORT(etag) ${href} → ${resp.responseCode}`); if (resp.responseCode < 200 || resp.responseCode >= 300) { - throw new Error(`服务器返回状态码 ${resp.responseCode}`); + throw new DavHttpError(resp.responseCode, `服务器返回状态码 ${resp.responseCode}`); } const xml: string = resp.result as string; const items: RemoteItem[] = []; @@ -403,11 +417,11 @@ export class DavClient { LogUtil.write(`HTTP REPORT(${compName}) ${href} → ${resp.responseCode}`); if (resp.responseCode === 401) { LogUtil.write(`HTTP REPORT(${compName}) 401 拒绝凭据`); - throw new Error('服务器拒绝凭据(401)'); + throw new DavHttpError(401, '服务器拒绝凭据(401)'); } if (resp.responseCode < 200 || resp.responseCode >= 300) { LogUtil.write(`HTTP REPORT(${compName}) 异常状态码 ${resp.responseCode}`); - throw new Error(`服务器返回状态码 ${resp.responseCode}`); + throw new DavHttpError(resp.responseCode, `服务器返回状态码 ${resp.responseCode}`); } const xml: string = resp.result as string; LogUtil.write(`HTTP REPORT(${compName}) 响应体 ${xml.length} 字符`); @@ -459,7 +473,7 @@ export class DavClient { console.info(`PUT ${url} 响应码: ${resp.responseCode}`); LogUtil.write(`HTTP PUT ${url} → ${resp.responseCode}`); if (resp.responseCode < 200 || resp.responseCode >= 300) { - throw new Error(`推送失败,服务器返回 ${resp.responseCode}`); + throw new DavHttpError(resp.responseCode, `推送失败,服务器返回 ${resp.responseCode}`); } const headers = resp.header as Record; if (headers !== undefined && headers !== null) { @@ -489,7 +503,7 @@ export class DavClient { LogUtil.write(`HTTP DELETE ${url} → ${resp.responseCode}`); // 404 视为已删除,成功 if ((resp.responseCode < 200 || resp.responseCode >= 300) && resp.responseCode !== 404) { - throw new Error(`删除失败,服务器返回 ${resp.responseCode}`); + throw new DavHttpError(resp.responseCode, `删除失败,服务器返回 ${resp.responseCode}`); } } finally { httpRequest.destroy(); diff --git a/entry/src/main/ets/common/EventDb.ets b/entry/src/main/ets/common/EventDb.ets index 8c78386..88c73a4 100644 --- a/entry/src/main/ets/common/EventDb.ets +++ b/entry/src/main/ets/common/EventDb.ets @@ -470,8 +470,13 @@ export class EventDb { added++; } else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate || found.reminder !== r.reminder || found.recurring !== r.recurring - || EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders)) { - // 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填) + || EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders) + || found.href !== href) { + // 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填; + // href 变化也要更新——服务器上日历本被删除重建后集合 URL 变了, + // 行里若残留旧 URL,用户下次编辑该日程会 PUT 到已不存在的集合(404), + // 且 pushDirty 按 href 过滤会漏掉它导致永远推不出去) + found.href = href; found.title = r.title; found.description = r.description; found.location = r.location; @@ -495,6 +500,15 @@ export class EventDb { updated++; } else { unchanged++; + // etag 未变也要校正 href:日历本被删除重建后集合 URL 变化,etag 比对仍然全部命中, + // 不在这里刷新的话旧 URL 会一直残留在行上(见上方更新分支的注释) + if (found.href !== href) { + found.href = href; + const hp = new relationalStore.RdbPredicates('events'); + hp.equalTo('id', found.id); + const hb: relationalStore.ValuesBucket = { 'href': href }; + await store.update(hb, hp); + } } } // 删除远端已不存在的(排除本地修改未推送的) @@ -645,6 +659,46 @@ export class EventDb { await store.delete(predicates); } + /** + * 删除某账号下、属于指定日历本集合(按 href 匹配)的全部本地日程/待办。 + * + * 用途:日历本在服务器上被删除(同步 REPORT 返回 404)或用户取消勾选时, + * 必须在 calKey 序号迁移**之前**先删掉这些行——否则序号收窄后,被移除日历本的 + * 旧行会与新搬到该序号下的其它日历本行混在同一个 calKey 里(2026-09-29 实测: + * 「读书」被服务器删除后重选,其 988 行混进「轻舟」的 calKey)。 + * 行上自带 href(日历本集合 URL),按 href 删比按 calKey 删更稳—— + * 历史原因(旧版迁移缺陷)行可能挂在错误的 calKey 下,href 才是行的真实归属。 + */ + static async pruneByHrefs(context: common.Context, accId: string, hrefs: string[]): Promise { + if (hrefs.length === 0) { + return 0; + } + const store = await EventDb.getDb(context); + const predicates = new relationalStore.RdbPredicates('events'); + predicates.like('cal_key', `${accId}_%`); + predicates.and().in('href', hrefs); + return await store.delete(predicates); + } + + /** + * 清理「href 已不属于任何已选日历本」的残留行(跨账号兜底清扫)。 + * 典型:服务器删除日历本后,旧版把其残留行混进其它日历本的 calKey(见 pruneByHrefs 注释), + * 这类行 calKey 看似有效、pruneOrphanCalKeys 抓不到,但 href 暴露了真实归属。 + * ⚠️ 跳过 dirty 行(用户未推送的本地修改绝不能被清扫掉)与 local 行。 + */ + static async pruneStaleHrefs(context: common.Context, validHrefs: string[]): Promise { + if (validHrefs.length === 0) { + return 0; + } + const store = await EventDb.getDb(context); + const predicates = new relationalStore.RdbPredicates('events'); + predicates.notEqualTo('cal_key', 'local'); + predicates.and().notEqualTo('href', ''); + predicates.and().notIn('href', validHrefs); + predicates.and().equalTo('dirty', 0); + return await store.delete(predicates); + } + /** * 迁移因「日历本序号变动」而错位的日程行(calKey = `${accId}_${序号}`)。 * diff --git a/entry/src/main/ets/common/SyncEngine.ets b/entry/src/main/ets/common/SyncEngine.ets index cfbb6bc..54484f1 100644 --- a/entry/src/main/ets/common/SyncEngine.ets +++ b/entry/src/main/ets/common/SyncEngine.ets @@ -2,12 +2,13 @@ // 双向同步引擎:先推本地修改(PUT/DELETE),再拉远端变更(REPORT + etag 增量) import { common } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; -import { DavAccount } from './AccountStore'; +import { DavAccount, AccountStore } from './AccountStore'; import { EventDb, LocalEvent, RemoteEvent } from './EventDb'; import { IcsUtil } from './IcsUtil'; -import { DavClient, DavColorEntry, RemoteItem } from './DavClient'; +import { DavClient, DavColorEntry, DavHttpError, RemoteItem } from './DavClient'; import { LogUtil } from './LogUtil'; import { AppSettings } from './AppSettings'; +import { SystemCalendarMirror } from './SystemCalendarMirror'; export class SyncEngine { /** 正在同步中的账号 id → 开始时间:防止超时后的"僵尸同步"与新一轮同步并发写库 */ @@ -91,13 +92,28 @@ export class SyncEngine { LogUtil.write('一次性全量重拉:忽略 etag 复用,全部资源 GET 完整 ICS'); } let changed: number = 0; + // 服务器上已不存在的日历本(REPORT 404):先跳过继续同步其它日历本, + // 循环结束后统一从账号移除(见 removeDeadBooks)。 + // 2026-09-29 实测:服务器上删掉一个日历本后,本地仍勾选它 → 每次同步 404 → + // 旧版直接抛错让整个账号同步失败,且后台同步每轮都失败(一天半没同步成功过)。 + const deadIdxs: number[] = []; for (let i = 0; i < acc.calendarHrefs.length; i++) { const href: string = acc.calendarHrefs[i]; const calKey: string = `${acc.id}_${i}`; const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`; LogUtil.write(`日历本[${i}]「${calName}」开始同步:${href}`); const t1: number = Date.now(); - const items: RemoteItem[] = await DavClient.reportEtags(href, auth); + let items: RemoteItem[] = []; + try { + items = await DavClient.reportEtags(href, auth); + } catch (err) { + if (err instanceof DavHttpError && (err as DavHttpError).status === 404) { + LogUtil.write(`日历本[${i}]「${calName}」在服务器上已不存在(404),本轮跳过,收尾时自动从账号移除`); + deadIdxs.push(i); + continue; + } + throw err as Error; + } LogUtil.write(`日历本[${i}]「${calName}」REPORT 返回 ${items.length} 个资源`); // 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag) const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event'); @@ -197,9 +213,92 @@ export class SyncEngine { LogUtil.write(`日历本[${i}]「${calName}」拉取待办失败(忽略):${te.message}`); } } + // 收尾:把本轮发现的"服务器上已删除"的日历本从账号移除并清理本地数据。 + // 放在循环后(而非循环中)是因为移除会让后续序号前移,循环中途改数组会打乱 calKey。 + if (deadIdxs.length > 0) { + await SyncEngine.removeDeadBooks(context, acc, deadIdxs); + } return changed; } + /** + * 从账号移除「服务器上已不存在(404)」的日历本:收窄配置、迁移序号、清理本地数据并持久化。 + * 只要有至少一个日历本保留就自动移除;若全部失效则不动配置(避免把账号清空), + * 抛错交给上层提示用户去服务器/编辑账号处理。 + */ + private static async removeDeadBooks(context: common.Context, acc: DavAccount, + deadIdxs: number[]): Promise { + if (deadIdxs.length >= acc.calendarHrefs.length) { + throw new Error('该账号下所有日历本在服务器上均已不存在(404),请检查服务器或重新编辑账号'); + } + const deadSet: Set = new Set(); + for (const d of deadIdxs) { + deadSet.add(d); + } + const removedHrefs: string[] = []; + const newHrefs: string[] = []; + const newNames: string[] = []; + const newColors: string[] = []; + const newWritable: string[] = []; + for (let i = 0; i < acc.calendarHrefs.length; i++) { + if (deadSet.has(i)) { + removedHrefs.push(acc.calendarHrefs[i]); + continue; + } + newHrefs.push(acc.calendarHrefs[i]); + newNames.push(i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`); + newColors.push(i < acc.calendarColors.length ? acc.calendarColors[i] : ''); + newWritable.push(i < acc.calendarWritable.length ? acc.calendarWritable[i] : '1'); + } + LogUtil.write(`自动移除 ${removedHrefs.length} 个在服务器上不存在的日历本,保留 ${newHrefs.length} 个`); + // 1) 先按 href 删掉被移除日历本的本地行——必须在序号迁移之前, + // 否则序号收窄后这些行会混进接替该序号的其它日历本(pruneByHrefs 注释详述) + await EventDb.pruneByHrefs(context, acc.id, removedHrefs); + // 2) 序号迁移:保留的日历本按原顺序收紧(旧序号 → 新序号) + const oldHrefs: string[] = acc.calendarHrefs.slice(); + const pairs: Array<[string, string]> = []; + let ni: number = 0; + for (let oi = 0; oi < oldHrefs.length; oi++) { + if (deadSet.has(oi)) { + continue; + } + if (ni !== oi) { + pairs.push([`${acc.id}_${oi}`, `${acc.id}_${ni}`]); + } + ni++; + } + if (pairs.length > 0) { + const moved: number = await EventDb.remapCalKeys(context, pairs); + LogUtil.write(`移除失效日历本后迁移日程 ${moved} 行(${pairs.length} 个日历本序号前移)`); + } + // 3) 收窄账号配置并持久化 + acc.calendarHrefs = newHrefs; + acc.calendarNames = newNames; + acc.calendarColors = newColors; + acc.calendarWritable = newWritable; + const all: DavAccount[] = await AccountStore.loadAll(context); + const idx: number = all.findIndex((a: DavAccount): boolean => a.id === acc.id); + if (idx >= 0) { + all[idx] = acc; + await AccountStore.saveAll(context, all); + } + // 4) 兜底:该账号下 calKey 不在有效列表的行全部清掉 + const validKeys: string[] = []; + for (let i = 0; i < newHrefs.length; i++) { + validKeys.push(`${acc.id}_${i}`); + } + await EventDb.pruneAccountEvents(context, acc.id, validKeys); + // 5) 若被移除的日历本正是「系统日历 → CalDAV」的备份目标,当场判失效并关闭备份 + try { + const backupCheck = await SystemCalendarMirror.checkBackupTarget(context); + if (backupCheck.closed) { + LogUtil.write('备份目标日历本已在服务器上删除,备份功能已自动关闭'); + } + } catch (err) { + // 检查失败不影响主流程 + } + } + /** * 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors * 失败静默(颜色不影响数据正确性) @@ -329,15 +428,25 @@ export class SyncEngine { LogUtil.write(`推送重复主事件(标题已脱敏)uid=${e.uid}`); } const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`; - if (e.deleted) { - await DavClient.deleteRemote(url, auth); - await EventDb.purge(context, e.id); - LogUtil.write(`推送删除(标题已脱敏)uid=${e.uid} → ${url}`); - } else { - const ics: string = IcsUtil.build(e); - const etag: string = await DavClient.putEvent(url, auth, ics); - await EventDb.clearDirty(context, e.id, etag); - LogUtil.write(`推送保存(标题已脱敏)uid=${e.uid} → ${url}(${ics.length} 字节,提醒=${e.reminders.map((n: number): string => String(n)).join('/')}分钟,VALARM=${ics.toUpperCase().includes('BEGIN:VALARM') ? '有' : '无'})`); + try { + if (e.deleted) { + await DavClient.deleteRemote(url, auth); + await EventDb.purge(context, e.id); + LogUtil.write(`推送删除(标题已脱敏)uid=${e.uid} → ${url}`); + } else { + const ics: string = IcsUtil.build(e); + const etag: string = await DavClient.putEvent(url, auth, ics); + await EventDb.clearDirty(context, e.id, etag); + LogUtil.write(`推送保存(标题已脱敏)uid=${e.uid} → ${url}(${ics.length} 字节,提醒=${e.reminders.map((n: number): string => String(n)).join('/')}分钟,VALARM=${ics.toUpperCase().includes('BEGIN:VALARM') ? '有' : '无'})`); + } + } catch (err) { + // 单条推送失败不再中断整个账号(旧行为:一条 404 就让整个账号同步失败)。 + // 401(凭据失效)例外:后续每条也必然失败,直接抛出让上层提示。 + if (err instanceof DavHttpError && (err as DavHttpError).status === 401) { + throw err as Error; + } + const pe = err as Error; + LogUtil.write(`推送单条失败(跳过,保留待推送):uid=${e.uid} → ${pe.message}`); } } } @@ -388,6 +497,19 @@ export class SyncEngine { if (removed > 0) { LogUtil.write(`清理孤儿日程行:${removed} 条(calKey 不属于任何现有账号)`); } + // 兜底清扫:行上 href 已不属于任何已选日历本的残留行(calKey 可能因历史迁移 + // 缺陷混在有效日历本名下,calKey 维度抓不到,href 才暴露真实归属)。 + // 典型如服务器删除日历本后残留的"幽灵"数据——不清掉的话镜像会一直把它写进系统日历。 + const validHrefs: string[] = []; + for (const a of accounts) { + for (const h of a.calendarHrefs) { + validHrefs.push(h); + } + } + const stale: number = await EventDb.pruneStaleHrefs(context, validHrefs); + if (stale > 0) { + LogUtil.write(`清理失效日历本残留行:${stale} 条(href 已不属于任何已选日历本)`); + } } catch (err) { const e = err as BusinessError; LogUtil.write(`清理孤儿日程行失败(忽略):${e.message}`); diff --git a/entry/src/main/ets/pages/EditAccountPage.ets b/entry/src/main/ets/pages/EditAccountPage.ets index 4fb47ac..c3f0319 100644 --- a/entry/src/main/ets/pages/EditAccountPage.ets +++ b/entry/src/main/ets/pages/EditAccountPage.ets @@ -224,6 +224,24 @@ struct EditAccountPage { target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color); LogUtil.write(`编辑账号保存:id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`); // 按 href 求出「旧序号 → 新序号」,迁移已落库日程到正确的 calKey + // ⭐ 迁移前必须先删掉被取消勾选日历本的行(按 href): + // 序号收窄后,被取消本的旧 calKey 会被前面位移下来的本"接管", + // 不先删的话旧行会与新主的行混在同一个 calKey 里(2026-09-29 实测: + // 服务器删除「读书」后重选,其 988 行混进「轻舟」的 calKey,镜像一直复活幽灵数据)。 + const newHrefSet: Set = new Set(); + for (const c of newHrefs) { + newHrefSet.add(c); + } + const removedHrefs: string[] = []; + for (const h of oldHrefs) { + if (!newHrefSet.has(h)) { + removedHrefs.push(h); + } + } + if (removedHrefs.length > 0) { + const pruned: number = await EventDb.pruneByHrefs(context, target.id, removedHrefs); + LogUtil.write(`编辑账号保存:取消勾选/已失效 ${removedHrefs.length} 个日历本,清理本地日程 ${pruned} 行`); + } const remapPairs: Array<[string, string]> = []; for (let oi: number = 0; oi < oldHrefs.length; oi++) { const oldNorm: string = DavClient.normalizeHref(oldHrefs[oi]);