8 Commits
Author SHA1 Message Date
laoyang cd2401242c 修复:编辑页挂了两个 bindSheet 导致时间选择框弹不出来
症状(用户反馈,本轮改动引入的回归):编辑/新建日程时点开始或结束时间,
白框一闪而过、日期时间选择器不出现。

根因:ArkUI 同一组件**只支持一个 bindSheet**,后挂的会覆盖先挂的。
上一轮新增"重复日程范围选择"弹层时,在同一根容器上挂了第二个 bindSheet,
于是它把原有的"时间选择"弹层顶掉了 —— 表现正是白框一闪即消失。

修法:合并为**唯一**一个 bindSheet,用 sheetMode 区分内容:
- sheetMode='picker' → 日期/时间选择(高度 380)
- sheetMode='scope'  → 重复日程范围选择(高度 320)
新增 bottomSheet() builder 按模式分发;原 pickerShow/scopeConfirmShow
两个状态合并为 sheetShow + sheetMode。
2026-10-09 22:41:17 +08:00
laoyang 5e6f347fc6 修复:编辑重复日程缺范围选择;同步并发不再误报失败
问题1(用户反馈):删除重复日程有"仅这一次/此次及以后/全部"三选项,编辑却没有。
根因:save() 把 validate() 与 chosenBook() 检查放在 isSeriesOccurrence() **之前**,
      任一不满足即提前 return,永远到不了范围选择弹层;delete 路径则是先判范围。
修法:
- save() 把"重复日程先选范围"提到最前(与删除路径一致),校验推迟到 doSave() 内
- chosenBook() 增加兜底:展开编辑时若该日程所属日历本不在"可写清单"里
  (被标记只读/写权限探测失败),用该日程自己的 calKey/href 构造目标;
  initPage 里还会用 CalendarDataBridge.lookupBook 把它补进选择列表(带真实名称/颜色)
- 新增 original 原始行快照:doSave 会就地改写 this.event 的 calKey/startTime,
  而 EXDATE/UNTIL/拆系列必须依据原值 —— 否则范围计算用错系列
- applySeriesEdit 的 one/future 分支在改写当前行后,用 EventDb.restoreRowById 还原原行,
  避免原系列残留幽灵行
- 加保存判定日志(脱敏)便于诊断

问题2(用户反馈):同步进行中再点手动同步/账号管理确定,提示"同步失败"造成误解。
根因:互斥锁抛的是普通 Error,三个调用点统一按"失败"提示。
修法:
- SyncEngine 新增 SyncBusyError 专用类型 + isSyncing/isAnySyncing 查询;
  锁命中时抛 SyncBusyError,且 catch 不再把它包成普通 Error
- Index.syncSingle / AccountsPage.syncSingleAccount:命中→提示
  "有同步任务正在进行中,请耐心等待",不显示失败
- Index.syncAll:命中记为 busySkipped(不算失败),全部被跳过时也只提示等待
2026-10-09 22:19:31 +08:00
laoyang 5c80c0b773 重复日程支持单次/后续/全部范围;时间轴刻度改为 8-18 固定
重复日程编辑与删除:
- 编辑或删除重复日程前先让用户选范围:仅这一次 / 此次及以后 / 全部
- 仅这一次:主事件加 EXDATE 排除该次,另插一条带 RECURRENCE-ID 的覆盖行
- 此次及以后:主事件 RRULE 加 UNTIL 截断,剩余部分拆成新 uid 的新系列
- 全部:删除/改写整个系列
- IcsUtil 新增 vevent / buildSeries / rruleWithUntil / fmtIcsTime:
  同一个 .ics 资源内写主事件 + 全部覆盖实例(标准写法),
  此前对覆盖实例直接跳过推送、导致单次改动无法上传服务器
- SyncEngine.pushSeries:按 uid 取全系列行重建整份 .ics 后一次性 PUT
- EventDb 新增 queryByUid / purgeByUid / purgeByUidFrom / reassignOverridesFrom /
  setExdate / setRruleForUid

时间轴刻度(App + 4x4/6x6 卡片同口径):
- 8:00~18:00 恒定显示;仅当 8 点前 / 18 点后有日程时才向该侧扩展,
  其余时刻不再出现'只有一条日程就只剩一小段刻度'的观感
2026-10-09 21:40:53 +08:00
laoyang d5e5d54d35 账号页空状态的「+」可点击;版本号升至 0.0.5(versionCode 500)
- AccountsPage.emptyState 的「+」改为可点击按钮,直接进入添加 CalDAV 账号页
  (此前只是静态图标,点了没反应);提示文案同步更新
- 版本号 0.0.4 → 0.0.5
2026-10-09 19:23:29 +08:00
laoyang c76c78b8a6 删除 CalDAV 账号时一并清除其系统日历镜像本
问题:在 App 里删除一个 CalDAV 账号后,之前镜像进系统日历的日历本
(synccalendar.mirror.<accId>-<hash> 账户) 仍留在系统日历里,形成幽灵账户。
真机实测确认:账号 1789564608312 已删,其名下 13 个镜像账户仍在(KEEP)。

根因:
- AccountsPage.confirmDeleteAccount 只清账号配置+本地库+设置,完全不碰系统日历;
- 自动兜底也失效:SystemCalendarMirror.syncNow 在解析不到任何选中本时提前 return,
  删完账号后正是此状态,purgeOrphans 永不执行。

修复:
- SystemCalendarMirror.removeByAccount(accId):按 'mirror.<accId>-' 前缀删除该账号全部
  镜像账户(兼容历史 '<accId>_' 桶名),逐个独立 try/catch + 清空日程降级;
  绝不按 displayName 匹配 —— 不同账号下的同名日历本不会误删;同步清快照与选中项设置。
- AccountsPage 删除账号时先 removeByAccount(必须在改账号配置前,否则 bookId 解析不出)。
- EditAccountPage 取消勾选日历本时删除其镜像账户(用 href 算稳定 bookId)。
- SettingsPage 账号库重置时 removeAll。
- syncNow 提前 return 前加 purgeOrphanAccountMirrors 兜底,带安全阀(账号列表为空即跳过,
  防读失败误删全量)。
2026-10-09 08:47:50 +08:00
laoyang 32162b8f04 修复折叠屏合盖旋转不再切换布局的回归
上一版为满足"接近1:1屏幕横竖屏布局一致",把宽屏判据改成纯短边≥840vp,
导致长条形屏幕(普通手机、折叠屏合上后的外屏)旋转横屏也不出双栏。
现在两条判据满足其一即双栏:
① 短边≥840vp(展开态/平板)——旋转不变,维持商店合规要求;
② 横屏且宽高比≥1.5(长条形屏幕)——旋转正常切换,恢复 0.0.2 行为。
判据实现由 mediaquery 改为 windowSizeChange(px→vp),旋转/折叠展开均实时生效,
并记录窗口尺寸日志便于真机排查。
2026-09-29 09:09:06 +08:00
laoyang b83ce83d43 版本号升至 0.0.4(versionCode 400) 2026-09-29 08:46:48 +08:00
laoyang d0b8720c41 同步容错:服务器删除日历本后 404 不再导致整个账号同步失败
- DavClient 新增 DavHttpError(带 HTTP 状态码),REPORT/PUT/DELETE 统一抛出
- 同步引擎对单本 404 跳过并自动从账号移除(先按 href 清行再迁移序号),
  全部日历本均 404 时保留账号配置并给出明确提示
- 推送阶段单条失败不再中断整个账号(401 凭据失效仍立即抛出)
- 编辑账号保存与自动移除都在序号迁移前先删被移除日历本的行,
  修复行混入其它日历本 calKey 的老问题(实测「读书」988 行混进「轻舟」)
- applyRemote 刷新行的 href:日历本被删除重建后集合 URL 变化,
  旧行不再残留死地址(编辑推送 404 / 永久卡待推送)
- pruneOrphanRows 追加 href 维度兜底清扫(跳过 dirty 行)
2026-09-29 08:46:01 +08:00
14 changed files with 1457 additions and 167 deletions
+2 -2
View File
@@ -2,8 +2,8 @@
"app": { "app": {
"bundleName": "synccalendar.yangyq.net", "bundleName": "synccalendar.yangyq.net",
"vendor": "yangyq", "vendor": "yangyq",
"versionCode": 300, "versionCode": 500,
"versionName": "0.0.3", "versionName": "0.0.5",
"buildVersion": "1", "buildVersion": "1",
"icon": "$media:layered_image", "icon": "$media:layered_image",
"label": "$string:app_name" "label": "$string:app_name"
+19 -5
View File
@@ -36,6 +36,20 @@ export class DavCalendarDiscovery {
entries: DavCalendarEntry[] = []; 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 { export class DavClient {
/** /**
* PROPFIND 拉取某路径下所有集合的 calendar-color * PROPFIND 拉取某路径下所有集合的 calendar-color
@@ -346,7 +360,7 @@ export class DavClient {
}); });
LogUtil.write(`HTTP REPORT(etag) ${href} → ${resp.responseCode}`); LogUtil.write(`HTTP REPORT(etag) ${href} → ${resp.responseCode}`);
if (resp.responseCode < 200 || resp.responseCode >= 300) { 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 xml: string = resp.result as string;
const items: RemoteItem[] = []; const items: RemoteItem[] = [];
@@ -403,11 +417,11 @@ export class DavClient {
LogUtil.write(`HTTP REPORT(${compName}) ${href} → ${resp.responseCode}`); LogUtil.write(`HTTP REPORT(${compName}) ${href} → ${resp.responseCode}`);
if (resp.responseCode === 401) { if (resp.responseCode === 401) {
LogUtil.write(`HTTP REPORT(${compName}) 401 拒绝凭据`); LogUtil.write(`HTTP REPORT(${compName}) 401 拒绝凭据`);
throw new Error('服务器拒绝凭据(401)'); throw new DavHttpError(401, '服务器拒绝凭据(401)');
} }
if (resp.responseCode < 200 || resp.responseCode >= 300) { if (resp.responseCode < 200 || resp.responseCode >= 300) {
LogUtil.write(`HTTP REPORT(${compName}) 异常状态码 ${resp.responseCode}`); 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; const xml: string = resp.result as string;
LogUtil.write(`HTTP REPORT(${compName}) 响应体 ${xml.length} 字符`); LogUtil.write(`HTTP REPORT(${compName}) 响应体 ${xml.length} 字符`);
@@ -459,7 +473,7 @@ export class DavClient {
console.info(`PUT ${url} 响应码: ${resp.responseCode}`); console.info(`PUT ${url} 响应码: ${resp.responseCode}`);
LogUtil.write(`HTTP PUT ${url} → ${resp.responseCode}`); LogUtil.write(`HTTP PUT ${url} → ${resp.responseCode}`);
if (resp.responseCode < 200 || resp.responseCode >= 300) { 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<string, string>; const headers = resp.header as Record<string, string>;
if (headers !== undefined && headers !== null) { if (headers !== undefined && headers !== null) {
@@ -489,7 +503,7 @@ export class DavClient {
LogUtil.write(`HTTP DELETE ${url} → ${resp.responseCode}`); LogUtil.write(`HTTP DELETE ${url} → ${resp.responseCode}`);
// 404 视为已删除,成功 // 404 视为已删除,成功
if ((resp.responseCode < 200 || resp.responseCode >= 300) && 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 { } finally {
httpRequest.destroy(); httpRequest.destroy();
+194 -2
View File
@@ -470,8 +470,13 @@ export class EventDb {
added++; added++;
} else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate } else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate
|| found.reminder !== r.reminder || found.recurring !== r.recurring || found.reminder !== r.reminder || found.recurring !== r.recurring
|| EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders)) { || EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders)
// 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填) || found.href !== href) {
// 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填;
// href 变化也要更新——服务器上日历本被删除重建后集合 URL 变了,
// 行里若残留旧 URL,用户下次编辑该日程会 PUT 到已不存在的集合(404),
// 且 pushDirty 按 href 过滤会漏掉它导致永远推不出去)
found.href = href;
found.title = r.title; found.title = r.title;
found.description = r.description; found.description = r.description;
found.location = r.location; found.location = r.location;
@@ -495,6 +500,15 @@ export class EventDb {
updated++; updated++;
} else { } else {
unchanged++; 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);
}
} }
} }
// 删除远端已不存在的(排除本地修改未推送的) // 删除远端已不存在的(排除本地修改未推送的)
@@ -610,6 +624,144 @@ export class EventDb {
await store.delete(predicates); await store.delete(predicates);
} }
/**
* ⭐ 取同一个 UID 在同一个日历本下的**全部行**(主事件 + 覆盖实例)。
* 用于"重复日程的单次/后续/全部"编辑与删除:重建整个系列后一次性推送。
* 只返回未删除的行。
*/
static async queryByUid(context: common.Context, calKey: string, uid: string): Promise<LocalEvent[]> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid);
predicates.orderByAsc('id');
const rs = await store.query(predicates);
const list: LocalEvent[] = [];
try {
while (rs.goToNextRow()) {
const e = EventDb.fromRow(rs);
if (!e.deleted) {
list.push(e);
}
}
} finally {
rs.close();
}
return list;
}
/** 物理删除某 uid 在某个日历本下的全部行("删除全部"用) */
static async purgeByUid(context: common.Context, calKey: string, uid: string): Promise<number> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid);
return await store.delete(predicates);
}
/** 物理删除某 uid 在某个日历本下、开始时间 >= from 的全部行("此次及以后"删除用) */
static async purgeByUidFrom(context: common.Context, calKey: string, uid: string,
from: number): Promise<number> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid)
.and().greaterThanOrEqualTo('start_time', from);
return await store.delete(predicates);
}
/**
* ⭐ 把某个 uid 系列下、开始时间 >= from 的行搬到另一个 uid("此次及以后"改期的实现):
* 主事件被截断(加 UNTIL)后,剩余部分作为**新的独立系列**存在,否则会被原系列覆盖。
* 返回被搬迁的行数。
*/
static async reassignUidFrom(context: common.Context, calKey: string, oldUid: string,
newUid: string, newPath: string, from: number): Promise<number> {
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = {
'uid': newUid,
'remote_path': newPath,
'etag': '',
'dirty': 1
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('uid', oldUid)
.and().greaterThanOrEqualTo('start_time', from);
return await store.update(bucket, predicates);
}
/** 给单行写入 EXDATE("仅删除这一次"→ 排除主事件的该次发生) */
static async setExdate(context: common.Context, id: number, exdate: string): Promise<void> {
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = { 'exdate': exdate, 'dirty': 1 };
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(bucket, predicates);
}
/**
* ⭐ 把某 uid 系列下、**RECURRENCE-ID >= from** 的覆盖实例行搬到新 uid
* ("此次及以后"拆分系列时用;主事件行 recurrenceId=0,天然不会被命中)。
*/
static async reassignOverridesFrom(context: common.Context, calKey: string, oldUid: string,
newUid: string, newPath: string, from: number): Promise<number> {
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = {
'uid': newUid,
'remote_path': newPath,
'etag': '',
'dirty': 1
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('uid', oldUid)
.and().greaterThanOrEqualTo('recurrence_id', from);
return await store.update(bucket, predicates);
}
/**
* ⭐ 给某个 uid 系列(指定日历本)的主事件写入 RRULE("此次及以后"截断/改期时用)。
* 只作用于有 RRULE 的主事件行。
*/
static async setRruleForUid(context: common.Context, calKey: string, uid: string,
rrule: string): Promise<void> {
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = { 'rrule': rrule, 'dirty': 1 };
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid)
.and().notEqualTo('rrule', '');
await store.update(bucket, predicates);
}
/** 新增一行(供拆分系列时插入新主事件;已置 dirty) */
static async insertRow(context: common.Context, e: LocalEvent): Promise<number> {
return await EventDb.insertLocal(context, e);
}
/**
* ⭐ 用一份原始快照**还原**某一行的内容(仅内容字段,不动 id)。
* 用途:编辑重复日程的"仅这一次 / 此次及以后"时,`doSave` 会把当前行就地写成新值,
* 但该行属于"原系列"(被 EXDATE 排除或被 UNTIL 截断),必须还原,
* 否则原系列里会残留一条被改过的幽灵行。
*/
static async restoreRowById(context: common.Context, id: number, src: LocalEvent): Promise<void> {
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = {
'title': src.title,
'description': src.description,
'location': src.location,
'start_time': src.startTime,
'end_time': src.endTime,
'is_all_day': src.isAllDay ? 1 : 0,
'rrule': src.rrule,
'exdate': src.exdate,
'recurrence_id': src.recurrenceId,
'recurring': src.recurring ? 1 : 0,
'reminder': src.reminders.length > 0 ? src.reminders[0] : src.reminder,
'reminders': src.reminders.map((n: number): string => String(n)).join(','),
'dirty': 1
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(bucket, predicates);
}
/** /**
* 清理孤儿行:calKey 既不是本机日历(local)、也不属于任何现有账号(accId_ 前缀)的历史残留数据。 * 清理孤儿行:calKey 既不是本机日历(local)、也不属于任何现有账号(accId_ 前缀)的历史残留数据。
* 典型来源:账号删除重建、日历重选后序号变化遗留的旧 calKey(如 `_13`、`acc1789274273124_0_13`)。 * 典型来源:账号删除重建、日历重选后序号变化遗留的旧 calKey(如 `_13`、`acc1789274273124_0_13`)。
@@ -645,6 +797,46 @@ export class EventDb {
await store.delete(predicates); 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<number> {
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<number> {
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}_${序号}`)。 * 迁移因「日历本序号变动」而错位的日程行(calKey = `${accId}_${序号}`)。
* *
+108 -8
View File
@@ -236,6 +236,42 @@ export class IcsUtil {
* 若线上出现"标题被截断"反馈,需在此补 escapeIcsText()。 * 若线上出现"标题被截断"反馈,需在此补 escapeIcsText()。
*/ */
static build(e: LocalEvent): string { static build(e: LocalEvent): string {
const now: string = IcsUtil.fmtUtcNow();
return 'BEGIN:VCALENDAR\r\n' +
'VERSION:2.0\r\n' +
'PRODID:-//SyncCalendar//Calendar//CN\r\n' +
'CALSCALE:GREGORIAN\r\n' +
IcsUtil.vevent(e, now) +
'END:VCALENDAR\r\n';
}
/**
* ⭐ 构建"一个重复系列"的完整资源:**主事件 + 全部覆盖实例**放在同一个 VCALENDAR 里。
*
* 为什么必须合在一起:CalDAV 里"重复日程的某一次发生被单独修改"的标准表示法是
* 在**同一个 .ics 资源**里放第二个 VEVENT,带 `RECURRENCE-ID` 指向被覆盖的那一次。
* 早先 App 直接把每个实例当独立资源 PUT,会破坏服务器上的整个系列,所以那时干脆
* 禁止了单次修改(见旧版"重复日程的单次修改暂不支持")。现在改为重建整份系列后推送。
*
* @param master 主事件行(含 RRULE / EXDATE)
* @param overrides 覆盖实例行(rrule 为空、recurrenceId > 0),已排除 deleted
*/
static buildSeries(master: LocalEvent, overrides: LocalEvent[]): string {
const now: string = IcsUtil.fmtUtcNow();
let out: string = 'BEGIN:VCALENDAR\r\n' +
'VERSION:2.0\r\n' +
'PRODID:-//SyncCalendar//Calendar//CN\r\n' +
'CALSCALE:GREGORIAN\r\n' +
IcsUtil.vevent(master, now);
for (const ov of overrides) {
out += IcsUtil.vevent(ov, now);
}
out += 'END:VCALENDAR\r\n';
return out;
}
/** 单个 VEVENT 块(主事件带 RRULE/EXDATE,覆盖行带 RECURRENCE-ID) */
private static vevent(e: LocalEvent, now: string): string {
const pad = (n: number): string => n < 10 ? '0' + n : String(n); const pad = (n: number): string => n < 10 ? '0' + n : String(n);
const fmtUtc = (ms: number): string => { const fmtUtc = (ms: number): string => {
const d = new Date(ms); const d = new Date(ms);
@@ -256,24 +292,88 @@ export class IcsUtil {
dtstart = 'DTSTART:' + fmtUtc(e.startTime); dtstart = 'DTSTART:' + fmtUtc(e.startTime);
dtend = 'DTEND:' + fmtUtc(e.endTime); dtend = 'DTEND:' + fmtUtc(e.endTime);
} }
const now: string = fmtUtc(Date.now()); // ⭐ 覆盖实例(RECURRENCE-ID 行):必须写出 RECURRENCE-ID,否则服务器无法把它
return 'BEGIN:VCALENDAR\r\n' + // 关联到主事件的哪一次发生 → 要么整系列被破坏,要么原时间残留成"幽灵日程"。
'VERSION:2.0\r\n' + let recId: string = '';
'PRODID:-//SyncCalendar//Calendar//CN\r\n' + if (e.rrule === '' && e.recurrenceId > 0) {
'CALSCALE:GREGORIAN\r\n' + recId = e.isAllDay
'BEGIN:VEVENT\r\n' + ? `RECURRENCE-ID;VALUE=DATE:${fmtDate(e.recurrenceId)}\r\n`
: `RECURRENCE-ID:${fmtUtc(e.recurrenceId)}\r\n`;
}
// ⭐ EXDATE:主事件排除掉的若干次发生("仅删除这一次"就是往这里加)
let exdate: string = '';
if (e.exdate !== '') {
const vals: string[] = [];
for (const raw of e.exdate.split(';')) {
const t = IcsUtil.parseTime(raw, !raw.includes('T'));
if (t !== null) {
vals.push(t.allDay ? fmtDate(t.time) : fmtUtc(t.time));
}
}
if (vals.length > 0) {
exdate = e.isAllDay
? `EXDATE;VALUE=DATE:${vals.join(',')}\r\n`
: `EXDATE:${vals.join(',')}\r\n`;
}
}
return 'BEGIN:VEVENT\r\n' +
`UID:${e.uid}\r\n` + `UID:${e.uid}\r\n` +
`DTSTAMP:${now}\r\n` + `DTSTAMP:${now}\r\n` +
`${dtstart}\r\n` + `${dtstart}\r\n` +
`${dtend}\r\n` + `${dtend}\r\n` +
recId +
(e.rrule !== '' ? `RRULE:${e.rrule}\r\n` : '') + (e.rrule !== '' ? `RRULE:${e.rrule}\r\n` : '') +
exdate +
`SUMMARY:${IcsUtil.escape(e.title)}\r\n` + `SUMMARY:${IcsUtil.escape(e.title)}\r\n` +
(e.location !== '' ? `LOCATION:${IcsUtil.escape(e.location)}\r\n` : '') + (e.location !== '' ? `LOCATION:${IcsUtil.escape(e.location)}\r\n` : '') +
(e.description !== '' ? `DESCRIPTION:${IcsUtil.escape(e.description)}\r\n` : '') + (e.description !== '' ? `DESCRIPTION:${IcsUtil.escape(e.description)}\r\n` : '') +
IcsUtil.valarms(e.reminders.length > 0 ? e.reminders IcsUtil.valarms(e.reminders.length > 0 ? e.reminders
: (e.reminder > 0 ? [e.reminder] : [])) + : (e.reminder > 0 ? [e.reminder] : [])) +
'END:VEVENT\r\n' + 'END:VEVENT\r\n';
'END:VCALENDAR\r\n'; }
/** 当前时刻的 UTC 形式(DTSTAMP) */
private static fmtUtcNow(): string {
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
const d = new Date();
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
}
/**
* 毫秒时间戳 → 用于 EXDATE / RECURRENCE-ID 的 ICS 时间串。
* @param dateOnly 全天日程用 `YYYYMMDD`,否则用 UTC 的 `YYYYMMDDTHHMMSSZ`
*/
static fmtIcsTime(ms: number, dateOnly: boolean): string {
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
const d = new Date(ms);
if (dateOnly) {
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
}
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
}
/**
* ⭐ 给 RRULE 加上/替换 UNTIL("此次及以后"改期/删除时,把原系列截断到指定时刻之前)。
* - 会**移除 COUNT**(RFC 5545:UNTIL 与 COUNT 不可同时出现)
* - UNTIL 用 UTC 形式;`untilMs` 一般是"该次发生的开始时刻 − 1 秒"
*/
static rruleWithUntil(rrule: string, untilMs: number): string {
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
const d = new Date(untilMs);
const until: string = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
const kept: string[] = [];
for (const part of rrule.split(';')) {
const k: string = part.split('=')[0].trim().toUpperCase();
if (k === 'UNTIL' || k === 'COUNT' || k === '') {
continue;
}
kept.push(part);
}
kept.push(`UNTIL=${until}`);
return kept.join(';');
} }
/** 由提醒分钟数组生成多个 VALARM(已规范化去重) */ /** 由提醒分钟数组生成多个 VALARM(已规范化去重) */
+249 -11
View File
@@ -2,12 +2,29 @@
// 双向同步引擎:先推本地修改(PUT/DELETE),再拉远端变更(REPORT + etag 增量) // 双向同步引擎:先推本地修改(PUT/DELETE),再拉远端变更(REPORT + etag 增量)
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount } from './AccountStore'; import { DavAccount, AccountStore } from './AccountStore';
import { EventDb, LocalEvent, RemoteEvent } from './EventDb'; import { EventDb, LocalEvent, RemoteEvent } from './EventDb';
import { IcsUtil } from './IcsUtil'; import { IcsUtil } from './IcsUtil';
import { DavClient, DavColorEntry, RemoteItem } from './DavClient'; import { DavClient, DavColorEntry, DavHttpError, RemoteItem } from './DavClient';
import { LogUtil } from './LogUtil'; import { LogUtil } from './LogUtil';
import { AppSettings } from './AppSettings'; import { AppSettings } from './AppSettings';
import { SystemCalendarMirror } from './SystemCalendarMirror';
/**
* ⭐ "已有同步任务正在进行"专用错误类型。
*
* 背景(用户反馈):后台同步进行中时,用户再点「手动同步」或在账号管理点「保存并同步」,
* 会并发触发第二轮同步;互斥锁把它拦下来,但那时抛的是普通 Error → 界面统一显示
* "同步失败:该账号正在同步中,请稍后再试"。用户看到"失败"会误以为出了问题。
*
* 正确做法:并发被拦下时**不是失败**,而应提示"有同步任务正在进行中,请耐心等待"。
* 因此用这个独立类型与真正的同步故障区分开,界面据此改提示语(而非报错)。
*/
export class SyncBusyError extends Error {
constructor() {
super('SYNC_BUSY');
}
}
export class SyncEngine { export class SyncEngine {
/** 正在同步中的账号 id → 开始时间:防止超时后的"僵尸同步"与新一轮同步并发写库 */ /** 正在同步中的账号 id → 开始时间:防止超时后的"僵尸同步"与新一轮同步并发写库 */
@@ -15,6 +32,27 @@ export class SyncEngine {
/** 互斥锁最长持有时间:超过视为异常残留(如进程挂起后网络停摆),允许抢占 */ /** 互斥锁最长持有时间:超过视为异常残留(如进程挂起后网络停摆),允许抢占 */
private static readonly LOCK_STALE_MS: number = 15 * 60 * 1000; private static readonly LOCK_STALE_MS: number = 15 * 60 * 1000;
/** 某账号当前是否正在同步(供界面判断,不触发任何操作) */
static isSyncing(accId: string): boolean {
const heldSince: number | undefined = SyncEngine.activeSyncs.get(accId);
if (heldSince === undefined) {
return false;
}
return Date.now() - heldSince < SyncEngine.LOCK_STALE_MS;
}
/** 当前是否有任意账号正在同步 */
static isAnySyncing(): boolean {
const now: number = Date.now();
for (const id of SyncEngine.activeSyncs.keys()) {
const since: number | undefined = SyncEngine.activeSyncs.get(id);
if (since !== undefined && now - since < SyncEngine.LOCK_STALE_MS) {
return true;
}
}
return false;
}
/** /**
* 给异步操作加超时保护,防止网络挂起导致界面一直转圈 * 给异步操作加超时保护,防止网络挂起导致界面一直转圈
*/ */
@@ -37,7 +75,8 @@ export class SyncEngine {
const heldSince: number | undefined = SyncEngine.activeSyncs.get(acc.id); const heldSince: number | undefined = SyncEngine.activeSyncs.get(acc.id);
if (heldSince !== undefined) { if (heldSince !== undefined) {
if (Date.now() - heldSince < SyncEngine.LOCK_STALE_MS) { if (Date.now() - heldSince < SyncEngine.LOCK_STALE_MS) {
throw new Error('该账号正在同步中,请稍后再试'); // ⭐ 抛专用类型:界面据此提示"同步进行中,请耐心等待"而非"失败"
throw new SyncBusyError();
} }
LogUtil.write(`同步互斥锁超时残留(账号 id=${acc.id}),强制释放并重新同步`); LogUtil.write(`同步互斥锁超时残留(账号 id=${acc.id}),强制释放并重新同步`);
} }
@@ -70,6 +109,10 @@ export class SyncEngine {
LogUtil.write(`同步账号 id=${acc.id} 完成:拉取 ${r} 条日程,耗时 ${Math.round((Date.now() - t0) / 1000)} 秒`); LogUtil.write(`同步账号 id=${acc.id} 完成:拉取 ${r} 条日程,耗时 ${Math.round((Date.now() - t0) / 1000)} 秒`);
return r; return r;
} catch (err) { } catch (err) {
// ⭐ "正在同步中"原样抛出(界面据此提示"请耐心等待",不当成失败)
if (err instanceof SyncBusyError) {
throw err;
}
const e = err as BusinessError; const e = err as BusinessError;
LogUtil.write(`同步账号 id=${acc.id} 失败:${e.message}(耗时 ${Math.round((Date.now() - t0) / 1000)} 秒)`); LogUtil.write(`同步账号 id=${acc.id} 失败:${e.message}(耗时 ${Math.round((Date.now() - t0) / 1000)} 秒)`);
throw new Error(e.message !== '' ? e.message : `错误码 ${e.code}`); throw new Error(e.message !== '' ? e.message : `错误码 ${e.code}`);
@@ -91,13 +134,28 @@ export class SyncEngine {
LogUtil.write('一次性全量重拉:忽略 etag 复用,全部资源 GET 完整 ICS'); LogUtil.write('一次性全量重拉:忽略 etag 复用,全部资源 GET 完整 ICS');
} }
let changed: number = 0; let changed: number = 0;
// 服务器上已不存在的日历本(REPORT 404):先跳过继续同步其它日历本,
// 循环结束后统一从账号移除(见 removeDeadBooks)。
// 2026-09-29 实测:服务器上删掉一个日历本后,本地仍勾选它 → 每次同步 404 →
// 旧版直接抛错让整个账号同步失败,且后台同步每轮都失败(一天半没同步成功过)。
const deadIdxs: number[] = [];
for (let i = 0; i < acc.calendarHrefs.length; i++) { for (let i = 0; i < acc.calendarHrefs.length; i++) {
const href: string = acc.calendarHrefs[i]; const href: string = acc.calendarHrefs[i];
const calKey: string = `${acc.id}_${i}`; const calKey: string = `${acc.id}_${i}`;
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`; const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
LogUtil.write(`日历本[${i}]「${calName}」开始同步:${href}`); LogUtil.write(`日历本[${i}]「${calName}」开始同步:${href}`);
const t1: number = Date.now(); 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} 个资源`); LogUtil.write(`日历本[${i}]「${calName}」REPORT 返回 ${items.length} 个资源`);
// 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag) // 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag)
const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event'); const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
@@ -197,9 +255,92 @@ export class SyncEngine {
LogUtil.write(`日历本[${i}]「${calName}」拉取待办失败(忽略):${te.message}`); LogUtil.write(`日历本[${i}]「${calName}」拉取待办失败(忽略):${te.message}`);
} }
} }
// 收尾:把本轮发现的"服务器上已删除"的日历本从账号移除并清理本地数据。
// 放在循环后(而非循环中)是因为移除会让后续序号前移,循环中途改数组会打乱 calKey。
if (deadIdxs.length > 0) {
await SyncEngine.removeDeadBooks(context, acc, deadIdxs);
}
return changed; return changed;
} }
/**
* 从账号移除「服务器上已不存在(404)」的日历本:收窄配置、迁移序号、清理本地数据并持久化。
* 只要有至少一个日历本保留就自动移除;若全部失效则不动配置(避免把账号清空),
* 抛错交给上层提示用户去服务器/编辑账号处理。
*/
private static async removeDeadBooks(context: common.Context, acc: DavAccount,
deadIdxs: number[]): Promise<void> {
if (deadIdxs.length >= acc.calendarHrefs.length) {
throw new Error('该账号下所有日历本在服务器上均已不存在(404),请检查服务器或重新编辑账号');
}
const deadSet: Set<number> = new Set<number>();
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 * 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors
* 失败静默(颜色不影响数据正确性) * 失败静默(颜色不影响数据正确性)
@@ -305,6 +446,9 @@ export class SyncEngine {
const dirty: LocalEvent[] = await EventDb.getDirty(context); const dirty: LocalEvent[] = await EventDb.getDirty(context);
const mine: LocalEvent[] = dirty.filter((e: LocalEvent): boolean => acc.calendarHrefs.includes(e.href)); const mine: LocalEvent[] = dirty.filter((e: LocalEvent): boolean => acc.calendarHrefs.includes(e.href));
LogUtil.write(`推送本地修改:全部待推送 ${dirty.length} 条,属于账号 id=${acc.id} 的 ${mine.length} 条`); LogUtil.write(`推送本地修改:全部待推送 ${dirty.length} 条,属于账号 id=${acc.id} 的 ${mine.length} 条`);
// ⭐ 同一个重复系列(uid + 日历本)在一次推送里只处理一次:整份 .ics(主事件 + 各覆盖实例)
// 一起重建后推送,避免"先 PUT 覆盖实例、后 PUT 主事件"互相覆盖。
const pushedSeries: Set<string> = new Set<string>();
for (const e of mine) { for (const e of mine) {
// 只读日历本:推送必然 403,跳过并保留 dirty(权限恢复后可再推) // 只读日历本:推送必然 403,跳过并保留 dirty(权限恢复后可再推)
const bookIdx: number = acc.calendarHrefs.indexOf(e.href); const bookIdx: number = acc.calendarHrefs.indexOf(e.href);
@@ -318,17 +462,20 @@ export class SyncEngine {
await EventDb.clearDirty(context, e.id, e.etag); await EventDb.clearDirty(context, e.id, e.etag);
continue; continue;
} }
if (e.recurring && e.rrule === '') { // ⭐ 重复系列(含覆盖实例):按 uid 取全系列行,重建整份 .ics 后一次性 PUT。
// 重复日程的"单次覆盖实例"(RECURRENCE-ID)推送会破坏服务器整个序列,暂不支持 // 旧版对"覆盖实例"直接跳过(不推送),导致 App 里的单次改期/删除无法上传到服务器;
await EventDb.clearDirty(context, e.id, e.etag); // 现在支持标准写法(同资源内多 VEVENT + RECURRENCE-ID)。
LogUtil.write(`推送跳过重复日程实例(标题已脱敏)uid=${e.uid}`); const seriesKey: string = `${e.calKey}|${e.uid}`;
if ((e.recurring || e.rrule !== '') && !pushedSeries.has(seriesKey)) {
pushedSeries.add(seriesKey);
await SyncEngine.pushSeries(context, acc, auth, e);
continue; continue;
} }
if (e.recurring) { if ((e.recurring || e.rrule !== '') && pushedSeries.has(seriesKey)) {
// 重复主事件(含 RRULE,含本机新建的重复日程):整条 PUT 覆盖推送 continue; // 该系列已在上面整体推送过
LogUtil.write(`推送重复主事件(标题已脱敏)uid=${e.uid}`);
} }
const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`; const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`;
try {
if (e.deleted) { if (e.deleted) {
await DavClient.deleteRemote(url, auth); await DavClient.deleteRemote(url, auth);
await EventDb.purge(context, e.id); await EventDb.purge(context, e.id);
@@ -339,6 +486,84 @@ export class SyncEngine {
await EventDb.clearDirty(context, e.id, etag); 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') ? '有' : '无'})`); 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}`);
}
}
}
/**
* ⭐ 推送一个"重复系列":取该 uid 下的主事件与全部覆盖实例,合成整份 .ics 后 PUT。
*
* - 主事件行若被标记删除(deleted)→ 视为"整个系列删除",DELETE 整个资源并清掉本地该 uid 全部行。
* - 否则重建(主事件可能带上了 EXDATE / 被截断的 UNTIL,覆盖实例带 RECURRENCE-ID),
* 一次性 PUT 覆盖服务器上的同一资源;成功后清掉本系列全部行的 dirty。
*/
private static async pushSeries(context: common.Context, acc: DavAccount, auth: string,
trigger: LocalEvent): Promise<void> {
const calKey: string = trigger.calKey;
const uid: string = trigger.uid;
const rows: LocalEvent[] = await EventDb.queryByUid(context, calKey, uid);
// 主事件:优先取带 RRULE 的行;退而取最早一行
let master: LocalEvent | null = null;
for (const r of rows) {
if (r.rrule !== '') {
master = r;
break;
}
}
if (master === null && rows.length > 0) {
master = rows[0];
}
if (master === null) {
return;
}
const href: string = master.href;
const path: string = master.remotePath !== '' ? master.remotePath : encodeURIComponent(uid) + '.ics';
const url: string = href.endsWith('/') ? href + path : `${href}/${path}`;
// 整个系列被删除(主事件行 deleted)→ DELETE 资源 + 清掉该 uid 全部本地行
if (master.deleted) {
try {
await DavClient.deleteRemote(url, auth);
} catch (err) {
if (err instanceof DavHttpError && (err as DavHttpError).status === 401) {
throw err as Error;
}
LogUtil.write(`推送删除重复系列失败:uid=${uid} → ${(err as Error).message}`);
return;
}
await EventDb.purgeByUid(context, calKey, uid);
LogUtil.write(`推送删除重复系列(标题已脱敏)uid=${uid} → ${url}`);
return;
}
const overrides: LocalEvent[] = [];
for (const r of rows) {
if (r.id !== master.id && r.rrule === '' && r.recurrenceId > 0) {
overrides.push(r);
}
}
overrides.sort((a: LocalEvent, b: LocalEvent): number => a.recurrenceId - b.recurrenceId);
const ics: string = IcsUtil.buildSeries(master, overrides);
try {
const etag: string = await DavClient.putEvent(url, auth, ics);
// 清掉本系列全部行的 dirty(第一条写回 etag)
let first: boolean = true;
for (const r of rows) {
await EventDb.clearDirty(context, r.id, first ? etag : '');
first = false;
}
LogUtil.write(`推送重复系列(标题已脱敏)uid=${uid} → ${url}(${ics.length} 字节,覆盖实例=${overrides.length})`);
} catch (err) {
if (err instanceof DavHttpError && (err as DavHttpError).status === 401) {
throw err as Error;
}
LogUtil.write(`推送重复系列失败(保留待推送):uid=${uid} → ${(err as Error).message}`);
} }
} }
@@ -388,6 +613,19 @@ export class SyncEngine {
if (removed > 0) { if (removed > 0) {
LogUtil.write(`清理孤儿日程行:${removed} 条(calKey 不属于任何现有账号)`); 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) { } catch (err) {
const e = err as BusinessError; const e = err as BusinessError;
LogUtil.write(`清理孤儿日程行失败(忽略):${e.message}`); LogUtil.write(`清理孤儿日程行失败(忽略):${e.message}`);
@@ -320,6 +320,14 @@ export class SystemCalendarMirror {
} }
// ⭐ 选中项一律走**稳定标识** bookId(由服务器 href 派生),并顺带迁移历史 calKey 选中项 // ⭐ 选中项一律走**稳定标识** bookId(由服务器 href 派生),并顺带迁移历史 calKey 选中项
const refs: BookRef[] = await SystemCalendarMirror.selectedBookRefs(context); const refs: BookRef[] = await SystemCalendarMirror.selectedBookRefs(context);
// ⭐ 兜底:无论有没有选中本,都顺手清掉"账号已删除"的孤儿镜像账户。
// 否则"删掉一个账号后系统日历里还留着它的日历本"永远不会被自动修掉
// (旧代码在这里 return 之前根本不做清理,这正是本次要修的 bug)。
try {
await SystemCalendarMirror.purgeOrphanAccountMirrors(context);
} catch (err) {
// 兜底清理失败不影响正常镜像
}
if (refs.length === 0) { if (refs.length === 0) {
LogUtil.write('镜像中止:未选择要镜像的日历本(或全部是备份目标本)'); LogUtil.write('镜像中止:未选择要镜像的日历本(或全部是备份目标本)');
return res; return res;
@@ -421,6 +429,175 @@ export class SystemCalendarMirror {
return SystemCalendarMirror.removeById(context, bookId); return SystemCalendarMirror.removeById(context, bookId);
} }
/**
* ⭐ 删除**某一个 DAV 账号**名下的全部镜像账户(在 App 里删除该账号时调用)。
*
* 背景(用户反馈):删掉一个 CalDAV 账号后,之前镜像进系统日历的那些"日历本"
* 仍然留在系统日历里(幽灵账户 + 幽灵日程)。旧代码只清了账号配置与本地库,
* 完全没碰系统日历侧;而自动兜底(syncNow 里的 purgeOrphans)在"解析不到任何选中本"时
* **会提前 return**,所以删完账号后根本不会触发兜底清理。
*
* ⭐ **绝不按日历本显示名匹配**:镜像账户名是 `synccalendar.mirror.<bookId>`,
* 而 bookId = `<accId>-<href哈希>` 天然带账号前缀。因此这里只按
* `synccalendar.mirror.<accId>-` 前缀匹配 —— 即使**不同账号下有同名日历本**
* (比如两个账号各有一个「工作」),它们的 bookId 不同、账户名也不同,绝不会删错。
*
* 幂等;未授权 / 无匹配时返回 0。每个账户独立 try/catch,一个失败不影响其它。
*
* @returns 成功删除的镜像账户数
*/
static async removeByAccount(context: common.Context, accId: string): Promise<number> {
if (accId === '' || !await SystemCalendarMirror.hasPermission()) {
return 0;
}
// ⭐ 账户名前缀 = 公共前缀 + accId + '-'(bookId 里的分隔符)。
// 带尾随 '-' 才不会把 accId「123」错配到 accId「1234」的账户上。
// 同时兼容**历史**账户名(旧版用 calKey 做桶 → `synccalendar.mirror.<accId>_<序号>`),
// 同样带尾随 '_' 分隔符,宁可多清一批脏账户,也不按显示名乱删。
const prefix: string = `${SystemCalendarMirror.ACCOUNT_PREFIX}${accId}-`;
const legacyPrefix: string = `${SystemCalendarMirror.ACCOUNT_PREFIX}${accId}_`;
let removed: number = 0;
const buckets: string[] = [];
try {
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context);
const all: calendarManager.Calendar[] = await cm.getAllCalendars();
for (const cal of all) {
const name: string = cal.getAccount().name;
if (!name.startsWith(prefix) && !name.startsWith(legacyPrefix)) {
continue; // 不属于本账号(含其它账号的同名日历本)→ 绝不动
}
const bucket: string = name.substring(SystemCalendarMirror.ACCOUNT_PREFIX.length);
try {
await cm.deleteCalendar(cal);
removed++;
buckets.push(bucket);
LogUtil.write(`删除账号 ${accId}:已删除镜像账户 id=${cal.id} name=${name}`);
} catch (err) {
const e1 = err as BusinessError;
LogUtil.write(`删除账号 ${accId}:删除镜像账户失败(重试) id=${cal.id} name=${name}: ${e1.message}`);
// 降级:先清空账户内全部日程,再删一次(与 purgeOrphans 同策略)
try {
const evs: calendarManager.Event[] = await cal.getEvents();
const ids: number[] = [];
for (const ev of evs) {
if (ev.id !== undefined) {
ids.push(ev.id);
}
}
if (ids.length > 0) {
await cal.deleteEvents(ids);
}
} catch (err2) {
LogUtil.write(`删除账号 ${accId}:清空镜像账户日程失败 id=${cal.id}: ${(err2 as BusinessError).message}`);
}
try {
await cm.deleteCalendar(cal);
removed++;
buckets.push(bucket);
LogUtil.write(`删除账号 ${accId}:已删除镜像账户(清空后重试成功) id=${cal.id} name=${name}`);
} catch (err3) {
LogUtil.write(`删除账号 ${accId}:镜像账户删除失败(最终) id=${cal.id} name=${name}: ${(err3 as BusinessError).message}`);
}
}
}
} catch (err) {
LogUtil.write(`删除账号 ${accId}:读取系统日历账户列表失败 ${(err as BusinessError).message}`);
}
// 清掉这些本在镜像快照里的残留(bucket = bookId)
for (const b of buckets) {
await MirrorSnapshot.removeByCalKey(context, b);
}
// 清掉"镜像选中项"里属于本账号的条目(防止残留脏 bookId 干扰下次选择/迁移)。
// ⚠️ 用 `${accId}-` / `${accId}_` 前缀过滤(同样带分隔符,避免误伤相似 accId)。
try {
const ids: string[] = await AppSettings.getMirrorBookIds(context);
const kept: string[] = ids.filter((id: string): boolean => !id.startsWith(`${accId}-`));
if (kept.length !== ids.length) {
await AppSettings.setMirrorBookIds(context, kept);
}
const legacy: string[] = await AppSettings.getMirrorKeys(context);
const keptLegacy: string[] = legacy.filter((k: string): boolean => !k.startsWith(`${accId}_`));
if (keptLegacy.length !== legacy.length) {
await AppSettings.setMirrorKeys(context, keptLegacy);
}
} catch (err) {
// 设置清理失败不影响账户删除结果,下次同步会再兜底
}
LogUtil.write(`删除账号 ${accId}:系统日历镜像清理完成,共删除 ${removed} 个镜像账户`);
return removed;
}
/**
* ⭐ 兜底:清理"账号已不存在"的孤儿镜像账户(同步时调用,防止删除路径万一漏掉)。
*
* ⚠️ **安全阀**:当前账号列表为空时**直接跳过** —— 无法区分"用户删光了账号"与
* "账号存储临时读失败返回空",后者若照常清理会把所有镜像账户误删。
* "删光账号"这种场景已由 `removeByAccount` / 重置账号库两条显式路径覆盖。
* 只要还剩至少一个账号,就只清"accId 前缀不在当前账号集合里"的那些 —— 精准、不会误伤。
*
* @returns 删除的账户数
*/
static async purgeOrphanAccountMirrors(context: common.Context): Promise<number> {
if (!await SystemCalendarMirror.hasPermission()) {
return 0;
}
let accounts: DavAccount[] = [];
try {
accounts = await AccountStore.loadAll(context);
} catch (err) {
return 0; // 读失败一律不动
}
if (accounts.length === 0) {
return 0; // 安全阀:见注释
}
const validAccIds: Set<string> = new Set<string>();
for (const a of accounts) {
validAccIds.add(a.id);
}
let removed: number = 0;
try {
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context);
const all: calendarManager.Calendar[] = await cm.getAllCalendars();
for (const cal of all) {
const name: string = cal.getAccount().name;
if (!name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX)) {
continue;
}
const bucket: string = name.substring(SystemCalendarMirror.ACCOUNT_PREFIX.length);
// bucket 形如 `<accId>-<hash>`;也兼容历史 `<accId>_<序号>`
const dash: number = bucket.indexOf('-');
const under: number = bucket.indexOf('_');
let sep: number = -1;
if (dash >= 0 && under >= 0) {
sep = Math.min(dash, under);
} else {
sep = dash >= 0 ? dash : under;
}
if (sep <= 0) {
continue; // 解析不出 accId → 不动(宁可留,不误删)
}
const accId: string = bucket.substring(0, sep);
if (validAccIds.has(accId)) {
continue;
}
try {
await cm.deleteCalendar(cal);
removed++;
LogUtil.write(`兜底清理孤儿镜像账户(账号 ${accId} 已删除) id=${cal.id} name=${name}`);
} catch (err) {
LogUtil.write(`兜底删除孤儿镜像账户失败 id=${cal.id} name=${name}: ${(err as BusinessError).message}`);
}
await MirrorSnapshot.removeByCalKey(context, bucket);
}
} catch (err) {
LogUtil.write(`兜底清理孤儿镜像账户:读取系统日历账户失败 ${(err as BusinessError).message}`);
}
if (removed > 0) {
LogUtil.write(`兜底清理孤儿镜像账户完成:删除 ${removed} 个`);
}
return removed;
}
/* ==================== 单个日历本 ==================== */ /* ==================== 单个日历本 ==================== */
/** /**
@@ -1224,6 +1401,18 @@ export class SystemCalendarMirror {
return `${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}`; return `${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}`;
} }
/**
* ⭐ 由 `<accId> + href` 算出稳定标识 bookId(= `<accId>-<href哈希>`)。
* 与 `listBooks()` 内部算法**完全一致**,供编辑账号页在"取消勾选某个日历本"时
* 立即算出它的 bookId 并删掉对应镜像账户用(此时账号配置已被改写,listBooks 已查不到它)。
* href 为空时退回 calKey(与 listBooks 的兜底口径一致)。
*/
static bookIdOf(accId: string, href: string, fallbackCalKey: string): string {
return href !== ''
? `${accId}-${SystemCalendarMirror.stableHash(href)}`
: fallbackCalKey;
}
/** 用账号信息补全日历本名与颜色(需要异步加载账号) */ /** 用账号信息补全日历本名与颜色(需要异步加载账号) */
static async loadBookLabels(context: common.Context, keys: string[]): Promise<BookInfo[]> { static async loadBookLabels(context: common.Context, keys: string[]): Promise<BookInfo[]> {
const out: BookInfo[] = []; const out: BookInfo[] = [];
+20 -15
View File
@@ -272,38 +272,43 @@ export class TimelineUtil {
return out; return out;
} }
/** 时间轴"基准可视区"的起止小时:8:00 ~ 18:00 **恒定显示**(与用户确认)。 */
static readonly BASE_START_HOUR: number = 8;
static readonly BASE_END_HOUR: number = 18;
/** /**
* 计算"需要显示的小时区间"(视窗截断): * 计算"需要显示的小时区间"(视窗截断)。规则(与用户确认):
* - 起点 = 最早日程所在整点(无日程默认 8 点),终点 = 最晚日程结束时刻向上取整(无日程默认 20 点) * - **8:00 ~ 18:00 恒定显示**(即使当天没有任何日程,也至少显示这一段,避免"只有一条日程
* - includeNowHour=true(今天)时,把当前时刻所在小时并进来,保证红线可见 * 就只显示那 1 小时"的奇怪观感);
* - 若某天在 **8 点之前**有日程 → 起点向前扩到最早日程所在整点;否则 8 点之前的刻度不显示;
* - 若某天在 **18 点之后**有日程 → 终点向后扩到最晚日程结束时刻向上取整;否则 18 点之后的刻度不显示。
* - includeNowHour=true(今天)时,把当前时刻所在小时并进来以保证红线可见 —— 但**仍遵守上面的
* "无日程则不显示"约束**:只有当该侧本来就有日程(起点已 <8 / 终点已 >18)时才允许继续向外扩,
* 否则不会因为"当前时刻"而把 8 点前 / 18 点后的空档重新显示出来。
* 视图渲染与"列表视图按天计算卡片高度"共用这一份逻辑,避免两边算法漂移。 * 视图渲染与"列表视图按天计算卡片高度"共用这一份逻辑,避免两边算法漂移。
*/ */
static viewRange(blocks: TimelineBlock[], includeNowHour: boolean, nowMs: number): ViewRange { static viewRange(blocks: TimelineBlock[], includeNowHour: boolean, nowMs: number): ViewRange {
const rg = new ViewRange(); const rg = new ViewRange();
let s: number = -1; // 基准:8~18 恒显示
let e: number = -1; let s: number = TimelineUtil.BASE_START_HOUR;
let e: number = TimelineUtil.BASE_END_HOUR;
for (const b of blocks) { for (const b of blocks) {
const bh: number = Math.floor(b.topRatio * 24); const bh: number = Math.floor(b.topRatio * 24);
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001); const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
if (s < 0 || bh < s) { if (bh < s) {
s = bh; s = bh;
} }
if (e < 0 || eh > e) { if (eh > e) {
e = eh; e = eh;
} }
} }
if (s < 0) {
s = 8;
}
if (e < 0) {
e = 20;
}
if (includeNowHour) { if (includeNowHour) {
const nh: number = Math.floor(TimelineUtil.nowRatio(nowMs) * 24); const nh: number = Math.floor(TimelineUtil.nowRatio(nowMs) * 24);
if (nh < s) { // 只有"该侧本来就有日程"时才允许把当前小时并进来(否则会违反"8 点前/18 点后无日程则不显示")
if (nh < s && s < TimelineUtil.BASE_START_HOUR) {
s = nh; s = nh;
} }
if (nh + 1 > e) { if (nh + 1 > e && e > TimelineUtil.BASE_END_HOUR) {
e = nh + 1; e = nh + 1;
} }
} }
+40 -11
View File
@@ -4,12 +4,13 @@ import { curves, router } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore, TYPE_KEYS, TYPE_CALDAV, TYPE_CARDDAV } from '../common/AccountStore'; import { DavAccount, AccountStore, TYPE_KEYS, TYPE_CALDAV, TYPE_CARDDAV } from '../common/AccountStore';
import { SyncEngine } from '../common/SyncEngine'; import { SyncEngine, SyncBusyError } from '../common/SyncEngine';
import { EditNavParams } from './EditAccountPage'; import { EditNavParams } from './EditAccountPage';
import { EventDb } from '../common/EventDb'; import { EventDb } from '../common/EventDb';
import { LogUtil } from '../common/LogUtil'; import { LogUtil } from '../common/LogUtil';
import { AppSettings } from '../common/AppSettings'; import { AppSettings } from '../common/AppSettings';
import { ScreenKeeper } from '../common/ScreenKeeper'; import { ScreenKeeper } from '../common/ScreenKeeper';
import { SystemCalendarMirror } from '../common/SystemCalendarMirror';
@Entry @Entry
@Component @Component
@@ -90,6 +91,12 @@ struct AccountsPage {
this.getUIContext().getPromptAction() this.getUIContext().getPromptAction()
.showToast({ message: `「${acc.name}」同步完成` }); .showToast({ message: `「${acc.name}」同步完成` });
} catch (err) { } catch (err) {
// ⭐ 已有同步在进行中 → 提示"请耐心等待",不报"失败"(避免用户误解)
if (err instanceof SyncBusyError) {
this.getUIContext().getPromptAction()
.showToast({ message: '有同步任务正在进行中,请耐心等待' });
return;
}
const e = err as BusinessError; const e = err as BusinessError;
this.getUIContext().getPromptAction() this.getUIContext().getPromptAction()
.showToast({ message: `「${acc.name}」同步失败:${e.message}` }); .showToast({ message: `「${acc.name}」同步失败:${e.message}` });
@@ -142,13 +149,24 @@ struct AccountsPage {
}); });
} }
/** 执行删除:账号配置 + 该账号全部本地日程/待办 */ /** 执行删除:账号配置 + 该账号全部本地日程/待办 + 该系统日历中该账号的全部镜像日历本 */
private async confirmDeleteAccount(acc: DavAccount): Promise<void> { private async confirmDeleteAccount(acc: DavAccount): Promise<void> {
const context = this.getUIContext().getHostContext(); const context = this.getUIContext().getHostContext();
if (context === undefined) { if (context === undefined) {
return; return;
} }
try { try {
// ⭐ 先清系统日历里属于**本账号**的镜像日历本(按 bookId 里的 accId 前缀匹配,
// 绝不按显示名 → 不同账号的同名日历本不会误删)。
// 必须在改账号配置**之前**做:一旦账号配置被删,bookId 就再也解析不出来,
// 那些幽灵账户就永远清不掉了(这正是本次要修的 bug)。
let mirrored: number = 0;
try {
mirrored = await SystemCalendarMirror.removeByAccount(context, acc.id);
} catch (err) {
// 系统日历清理失败不阻断账号删除(用户至少能删掉账号)
LogUtil.write(`删除账号 ${acc.id}:清理系统日历镜像失败 ${(err as BusinessError).message}`);
}
this.accounts = this.accounts.filter((a: DavAccount): boolean => a.id !== acc.id); this.accounts = this.accounts.filter((a: DavAccount): boolean => a.id !== acc.id);
// force=true:删除最后一个账号时列表为空是合法场景,允许覆盖 // force=true:删除最后一个账号时列表为空是合法场景,允许覆盖
await AccountStore.saveAll(context, this.accounts, true); await AccountStore.saveAll(context, this.accounts, true);
@@ -156,7 +174,11 @@ struct AccountsPage {
await EventDb.deleteAccountEvents(context, acc.id); await EventDb.deleteAccountEvents(context, acc.id);
await AppSettings.cleanupAccountSettings(context, acc.id); await AppSettings.cleanupAccountSettings(context, acc.id);
this.getUIContext().getPromptAction() this.getUIContext().getPromptAction()
.showToast({ message: `「${acc.name}」已删除` }); .showToast({
message: mirrored > 0
? `「${acc.name}」已删除,并清除系统日历中 ${mirrored} 个镜像日历本`
: `「${acc.name}」已删除`
});
} catch (err) { } catch (err) {
const e = err as BusinessError; const e = err as BusinessError;
this.getUIContext().getPromptAction() this.getUIContext().getPromptAction()
@@ -268,20 +290,27 @@ struct AccountsPage {
@Builder @Builder
emptyState() { emptyState() {
Column({ space: 12 }) { Column({ space: 12 }) {
// ⭐ 空状态的「+」本身可点击 → 直接进入添加账号页(此前只是个静态图标,点了没反应)
Button() {
Text('+') Text('+')
.fontSize(30) .fontSize(34)
.fontColor($r('app.color.text_hint')) .fontColor($r('app.color.brand'))
.width(72) .fontWeight(FontWeight.Medium)
.height(72) }
.textAlign(TextAlign.Center) .width(84)
.borderRadius(20) .height(84)
.borderRadius(24)
.backgroundColor($r('app.color.card_bg')) .backgroundColor($r('app.color.card_bg'))
.border({ width: 1.5, color: $r('app.color.shadow_color') }) .border({ width: 1.5, color: $r('app.color.brand') })
.shadow({ radius: 8, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 2 })
.onClick(() => {
this.openAddPage();
})
Text('还没有任何 CalDAV 账号') Text('还没有任何 CalDAV 账号')
.fontSize(16) .fontSize(16)
.fontWeight(FontWeight.Medium) .fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary')) .fontColor($r('app.color.text_primary'))
Text('点击右下角 + 添加 CalDAV 账号') Text('点击上方 + 或右下角 + 添加 CalDAV 账号')
.fontSize(13) .fontSize(13)
.fontColor($r('app.color.text_secondary')) .fontColor($r('app.color.text_secondary'))
.textAlign(TextAlign.Center) .textAlign(TextAlign.Center)
@@ -224,6 +224,39 @@ struct EditAccountPage {
target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color); target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color);
LogUtil.write(`编辑账号保存:id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`); LogUtil.write(`编辑账号保存:id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
// 按 href 求出「旧序号 → 新序号」,迁移已落库日程到正确的 calKey // 按 href 求出「旧序号 → 新序号」,迁移已落库日程到正确的 calKey
// ⭐ 迁移前必须先删掉被取消勾选日历本的行(按 href):
// 序号收窄后,被取消本的旧 calKey 会被前面位移下来的本"接管",
// 不先删的话旧行会与新主的行混在同一个 calKey 里(2026-09-29 实测:
// 服务器删除「读书」后重选,其 988 行混进「轻舟」的 calKey,镜像一直复活幽灵数据)。
const newHrefSet: Set<string> = new Set<string>();
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} 行`);
}
// ⭐ 被取消勾选的日历本,其系统日历镜像账户也要一并删掉(否则取消同步后系统日历仍有幽灵账户)。
// 此时账号配置即将被改写、listBooks 已解析不到它,故用 href 直接算稳定 bookId(与镜像侧同算法)。
// ⚠️ 用稳定 bookId 删 —— 不同账号下有同名日历本时,bookId 里带 accId 前缀,绝不会删错。
for (let oi: number = 0; oi < oldHrefs.length; oi++) {
const oh: string = oldHrefs[oi];
if (newHrefSet.has(oh)) {
continue;
}
const bookId: string = SystemCalendarMirror.bookIdOf(target.id, oh, `${target.id}_${oi}`);
try {
await SystemCalendarMirror.removeById(context, bookId);
} catch (err) {
// 镜像账户删除失败不阻断保存(下次镜像前的孤儿清理还会兜底)
}
}
const remapPairs: Array<[string, string]> = []; const remapPairs: Array<[string, string]> = [];
for (let oi: number = 0; oi < oldHrefs.length; oi++) { for (let oi: number = 0; oi < oldHrefs.length; oi++) {
const oldNorm: string = DavClient.normalizeHref(oldHrefs[oi]); const oldNorm: string = DavClient.normalizeHref(oldHrefs[oi]);
+488 -23
View File
@@ -11,6 +11,15 @@ import { DavClient } from '../common/DavClient';
import { SyncEngine } from '../common/SyncEngine'; import { SyncEngine } from '../common/SyncEngine';
import { ReminderService } from '../common/ReminderService'; import { ReminderService } from '../common/ReminderService';
import { LogUtil } from '../common/LogUtil'; import { LogUtil } from '../common/LogUtil';
import { IcsUtil } from '../common/IcsUtil';
import { RruleUtil } from '../common/RruleUtil';
/** 重复系列某次发生的前后边界(用于 EXDATE / UNTIL / 拆分) */
class SeriesBounds {
thisOcc: number = 0; // 本次目标的(原始)发生时刻
beforeThis: number = 0; // 此次之前一点(截断 UNTIL 用),= 上一次与本次的中点或本次-1s
nextOcc: number = 0; // 下一次发生时刻(0 = 已是最后一次)
}
/** 可选的日历本 */ /** 可选的日历本 */
class BookChoice { class BookChoice {
@@ -36,11 +45,32 @@ struct EventEditPage {
@State isExisting: boolean = false; @State isExisting: boolean = false;
@State repeatMode: string = 'none'; // none|daily|workday|weekly|monthly|yearly|custom @State repeatMode: string = 'none'; // none|daily|workday|weekly|monthly|yearly|custom
@State reminderList: number[] = []; // 提前提醒分钟数(多选),空 = 不提醒 @State reminderList: number[] = []; // 提前提醒分钟数(多选),空 = 不提醒
@State pickerShow: boolean = false; // 开始/结束时间选择底部弹层(日期+时间一次选完) /**
* ⭐ 底部弹层:**整个页面只能挂一个 `bindSheet`**。
* ArkUI 里同一组件挂多个 bindSheet 时,后一个会覆盖前一个 —— 实测表现就是
* "时间选择框白框一闪而过、什么都没出来"(范围弹层把时间弹层顶掉了)。
* 因此这里用一个状态 + 一个模式来复用同一个弹层:
* sheetMode = 'picker' → 日期/时间选择
* sheetMode = 'scope' → 重复日程范围选择
*/
@State sheetShow: boolean = false;
@State sheetMode: string = 'picker';
@State pickerIsEnd: boolean = false; @State pickerIsEnd: boolean = false;
@State pickDate: Date = new Date(); @State pickDate: Date = new Date();
@State pickHour: number = 9; @State pickHour: number = 9;
@State pickMin: number = 0; @State pickMin: number = 0;
/** ⭐ 重复日程编辑范围:'one' 仅此次 / 'future' 此次及以后 / 'all' 全部 */
@State scope: string = 'all';
private pendingDelete: boolean = false; // 该弹层是"保存"还是"删除"流程
/**
* ⭐ 载入时的**原始行快照**(不可变)。
* `doSave` 会就地改写 `this.event` 的 calKey/href/startTime 等字段,
* 而"重复系列"的范围计算(EXDATE / UNTIL / 拆系列)必须依据**原始**的
* 系列标识(原 calKey、原 href、原发生时刻),否则会算错。
*/
private original: LocalEvent | null = null;
/** 本次编辑的"目标发生时刻"(未改期时 = 原发生时刻;用户改了开始时间 → 新时刻) */
private occStartMs: number = 0;
private event: LocalEvent | null = null; private event: LocalEvent | null = null;
aboutToAppear(): Promise<void> { aboutToAppear(): Promise<void> {
@@ -76,6 +106,22 @@ struct EventEditPage {
const loaded = await EventDb.getById(context, pendingId); const loaded = await EventDb.getById(context, pendingId);
if (loaded !== null) { if (loaded !== null) {
this.event = loaded; this.event = loaded;
// ⭐ 原始行快照:series 范围计算必须用"载入时"的值(doSave 会改写 this.event)
const snap = new LocalEvent();
snap.id = loaded.id;
snap.uid = loaded.uid;
snap.calKey = loaded.calKey;
snap.href = loaded.href;
snap.remotePath = loaded.remotePath;
snap.startTime = loaded.startTime;
snap.endTime = loaded.endTime;
snap.recurrenceId = loaded.recurrenceId;
snap.rrule = loaded.rrule;
snap.exdate = loaded.exdate;
snap.isAllDay = loaded.isAllDay;
snap.recurring = loaded.recurring;
snap.deleted = loaded.deleted;
this.original = snap;
this.isExisting = true; this.isExisting = true;
this.title = loaded.title; this.title = loaded.title;
this.location = loaded.location; this.location = loaded.location;
@@ -84,6 +130,22 @@ struct EventEditPage {
this.startMs = loaded.startTime; this.startMs = loaded.startTime;
this.endMs = loaded.endTime; this.endMs = loaded.endTime;
this.chosenKey = loaded.calKey; this.chosenKey = loaded.calKey;
// ⭐ 若这条日程所属的日历本不在"可写日历本"列表里(例如该本被标记只读、
// 或服务器写权限探测暂时失败),把它补进选择列表,保证 chosenBook() 一定能解析到 ——
// 否则保存会被拦下,用户永远进不到"重复日程范围选择"这一步。
if (loaded.calKey !== LOCAL_CAL_KEY
&& !choices.some((c: BookChoice): boolean => c.calKey === loaded.calKey)) {
const info: BookLookup | null =
await CalendarDataBridge.lookupBook(context, loaded.calKey, loaded.href);
if (info !== null) {
const b = new BookChoice();
b.calKey = info.calKey;
b.href = info.href;
b.name = info.name;
b.color = info.color;
this.books = this.books.concat([b]);
}
}
// 历史遗留的"本机"日程:本应用已不再支持本机日程,改为引导用户挑一个 CalDAV 日历本 // 历史遗留的"本机"日程:本应用已不再支持本机日程,改为引导用户挑一个 CalDAV 日历本
// (保存时 calKey/href 会改写为该日历本,事件随后被推送到服务器) // (保存时 calKey/href 会改写为该日历本,事件随后被推送到服务器)
if (loaded.calKey === LOCAL_CAL_KEY) { if (loaded.calKey === LOCAL_CAL_KEY) {
@@ -92,6 +154,8 @@ struct EventEditPage {
this.repeatMode = loaded.rrule !== '' ? this.modeFromRrule(loaded.rrule) : 'none'; this.repeatMode = loaded.rrule !== '' ? this.modeFromRrule(loaded.rrule) : 'none';
this.reminderList = loaded.reminders.length > 0 ? loaded.reminders this.reminderList = loaded.reminders.length > 0 ? loaded.reminders
: (loaded.reminder > 0 ? [loaded.reminder] : []); : (loaded.reminder > 0 ? [loaded.reminder] : []);
// ⭐ 目标发生时刻:优先用 RECURRENCE-ID(覆盖行=被改写的原始时刻),否则用行本身开始时间
this.occStartMs = loaded.recurrenceId > 0 ? loaded.recurrenceId : loaded.startTime;
return; return;
} }
} }
@@ -108,7 +172,22 @@ struct EventEditPage {
} }
private chosenBook(): BookChoice | null { private chosenBook(): BookChoice | null {
return this.books.find((b: BookChoice): boolean => b.calKey === this.chosenKey) ?? null; const found = this.books.find((b: BookChoice): boolean => b.calKey === this.chosenKey);
if (found !== undefined) {
return found;
}
// ⭐ 兜底:编辑既有日程时,若它的日历本恰好不在"可写日历本"列表里
// (例如该本被标记只读、或服务器写权限探测暂时失败),
// 仍然用这条日程自己的 calKey/href 构造一个目标 —— 否则 chosenBook() 返回 null,
// 保存会被直接拦下,用户只看到一行错误提示,永远进不到"重复日程范围选择"。
if (this.event !== null && this.event.calKey !== '' && this.event.calKey === this.chosenKey) {
const b = new BookChoice();
b.calKey = this.event.calKey;
b.href = this.event.href;
b.name = '';
return b;
}
return null;
} }
private fmtDate(ms: number): string { private fmtDate(ms: number): string {
@@ -183,7 +262,8 @@ struct EventEditPage {
this.pickDate = new Date(base.getFullYear(), base.getMonth(), base.getDate()); this.pickDate = new Date(base.getFullYear(), base.getMonth(), base.getDate());
this.pickHour = base.getHours(); this.pickHour = base.getHours();
this.pickMin = base.getMinutes(); this.pickMin = base.getMinutes();
this.pickerShow = true; this.sheetMode = 'picker';
this.sheetShow = true;
} }
private applyPicker(): void { private applyPicker(): void {
@@ -226,17 +306,240 @@ struct EventEditPage {
return true; return true;
} }
private async save(): Promise<void> { /**
if (this.isSaving || !this.validate()) { * 当前编辑的是不是一个"重复系列中的某一次发生"?
* - 主事件行(rrule 非空):用户点开的可能就是第一次发生或其展开实例 → 需要询问范围
* - 覆盖实例行(recurring && rrule==''):明确就是被单独改写过的那一次 → 需要询问范围
* 非重复的普通日程返回 false,直接按原逻辑保存/删除。
*/
private isSeriesOccurrence(): boolean {
const src: LocalEvent | null = this.original ?? this.event;
if (src === null) {
return false;
}
return src.recurring || src.rrule !== '';
}
/** 取本系列的主事件行(带 RRULE 的那行;找不到则退回当前行)。
* ⚠️ 必须按**原始** calKey/uid 查找(doSave 会改写 this.event 的 calKey)。 */
private async loadMaster(context: common.Context): Promise<LocalEvent | null> {
const src: LocalEvent | null = this.original ?? this.event;
if (src === null) {
return null;
}
const rows: LocalEvent[] = await EventDb.queryByUid(context, src.calKey, src.uid);
for (const r of rows) {
if (r.rrule !== '') {
return r;
}
}
return src;
}
/** 该次发生之前一次发生与之后一次发生的边界(用于 EXDATE / UNTIL / 拆分) */
private async seriesBoundaries(context: common.Context): Promise<SeriesBounds> {
const b = new SeriesBounds();
const occ: number = this.occStartMs;
b.thisOcc = occ;
const master: LocalEvent | null = await this.loadMaster(context);
if (master === null) {
b.beforeThis = occ - 1000;
b.nextOcc = 0;
return b;
}
const exNums: number[] = [];
if (master.exdate !== '') {
for (const raw of master.exdate.split(';')) {
const t = IcsUtil.parseTime(raw, !raw.includes('T'));
if (t !== null) {
exNums.push(t.time);
}
}
}
// 展开窗口:主事件开始 ~ "该次"之后一年的发生,用于找"前后相邻"的两次
const winEnd: number = occ + 366 * 86400000;
const occs: number[] = RruleUtil.expand(master.rrule, master.startTime,
master.startTime, winEnd, exNums, 3000);
let prev: number = 0;
let next: number = 0;
for (const o of occs) {
if (o < occ) {
prev = o;
} else if (o > occ) {
next = o;
break;
}
}
b.beforeThis = prev > 0 ? (prev + occ) / 2 : (occ - 1000);
b.nextOcc = next;
return b;
}
/**
* ⭐ 按用户选择的"重复日程编辑范围"落库:
* - all :原逻辑,直接改这一行(若是覆盖实例则改覆盖行),整系列由 pushSeries 重建
* - one :单次改写 ——
* · 原行是覆盖实例 → 直接更新它
* · 原行是主事件(用户点开了系列)→ 为主事件加 EXDATE 排除该次,另插一条覆盖行
* - future :此次及以后 —— 先把主事件的 RRULE 截断到"此次之前"(加 UNTIL),
* 再把"此次及以后"的内容作为**新的独立系列**(新 uid)插入。
*/
private async applySeriesEdit(context: common.Context, e: LocalEvent, isNew: boolean,
scope: string): Promise<void> {
const target: LocalEvent = e;
// ⚠️ 用**原始快照**判据:doSave 已把 e(= this.event)的 calKey/href/startTime 改成新值,
// 直接用 this.event 会把"改后的日期/日历本"当成原系列,导致 EXDATE / UNTIL 算错。
const cur: LocalEvent | null = this.original;
if (isNew || cur === null || !this.isSeriesOccurrence()) {
if (isNew) {
await EventDb.insertLocal(context, target);
} else {
await EventDb.updateLocal(context, target);
}
return; return;
} }
// 仅阻止"单次覆盖实例"(RECURRENCE-ID)的修改;重复主事件(含 RRULE)允许编辑 const bounds: SeriesBounds = await this.seriesBoundaries(context);
if (this.event !== null && this.event.recurring && this.event.rrule === '') { if (scope === 'all') {
this.statusMsg = '重复日程的单次修改暂不支持,请到服务器端调整重复规则'; // 全部:直接更新当前行。若当前行是某次覆盖实例 → 保留其 recurrenceId 改内容;
// 若当前行是主事件 → 更新主事件(DTSTART 若被改 → 整系列平移,符合"修改全部"直觉)
await EventDb.updateLocal(context, target);
return;
}
if (scope === 'one') {
if (cur.rrule === '' && cur.recurrenceId > 0) {
// 已经是覆盖实例 → 直接改它(保留 recurrenceId)
target.recurrenceId = cur.recurrenceId;
await EventDb.updateLocal(context, target);
return;
}
// 主事件上的"仅此次":加 EXDATE 排除该次 + 插入覆盖行
const master: LocalEvent | null = await this.loadMaster(context);
if (master === null) {
return;
}
const exList: string[] = master.exdate === '' ? [] : master.exdate.split(';');
const occIcs: string = IcsUtil.fmtIcsTime(bounds.thisOcc, master.isAllDay);
if (!exList.includes(occIcs)) {
exList.push(occIcs);
}
await EventDb.setExdate(context, master.id, exList.join(';'));
// ⚠️ 主事件行本身的内容保持不变("仅这一次"只影响这一次);
// 如果用户是在主事件行上改的,需要把主事件改回原值(target 已被写成新时间)
if (cur.rrule !== '') {
await EventDb.restoreRowById(context, master.id, cur);
}
// 覆盖行:uid 同主事件、DTSTART=改到的新时间、RECURRENCE-ID=原始发生时刻
const ov = new LocalEvent();
ov.uid = master.uid;
ov.calKey = master.calKey;
ov.href = master.href;
ov.remotePath = master.remotePath;
ov.title = target.title;
ov.description = target.description;
ov.location = target.location;
ov.startTime = target.startTime;
ov.endTime = target.endTime;
ov.isAllDay = target.isAllDay;
ov.recurring = true;
ov.rrule = '';
ov.recurrenceId = bounds.thisOcc;
ov.reminders = target.reminders;
ov.reminder = target.reminder;
await EventDb.insertLocal(context, ov);
return;
}
// scope === 'future'
const master: LocalEvent | null = await this.loadMaster(context);
if (master === null) {
return;
}
// 主事件本身被改期 → "此次及以后"里包含主事件(第一次发生)本身,
// 需要让新系列从"改到的时间"开始(否则原 DTSTART 会被原系列继续展开)。
const startedAtThis: boolean =
(cur.rrule !== '' && Math.abs(cur.startTime - bounds.thisOcc) < 1000);
// 1) 先把"此次及以后"的覆盖实例行搬到新 uid(主事件行 recurrenceId=0,不受影响)
const newUid: string = `syncal-${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
const newPath: string = encodeURIComponent(newUid) + '.ics';
await EventDb.reassignOverridesFrom(context, master.calKey, master.uid,
newUid, newPath, bounds.thisOcc);
// 2) 创建新系列主事件(本次编辑内容)
const newMaster = new LocalEvent();
newMaster.uid = newUid;
newMaster.calKey = master.calKey;
newMaster.href = master.href;
newMaster.remotePath = newPath;
newMaster.title = target.title;
newMaster.description = target.description;
newMaster.location = target.location;
newMaster.startTime = target.startTime;
newMaster.endTime = target.endTime;
newMaster.isAllDay = target.isAllDay;
newMaster.recurring = true;
// 新的重复规则:沿用原系列(用户没显式改规则时),保持"此次及以后"的重复节奏
newMaster.rrule = target.rrule !== '' ? target.rrule : master.rrule;
newMaster.reminders = target.reminders;
newMaster.reminder = target.reminder;
await EventDb.insertLocal(context, newMaster);
// 3) 原主事件系列截断到"此次之前"。
// 若主事件本身就代表"此次"(第一次发生且被改期)→ 原系列整个让位给新系列,
// 直接标记原主事件删除(pushSeries 会 DELETE 原资源),避免旧时间残留成幽灵日程。
if (startedAtThis) {
await EventDb.markDeleted(context, master.id);
} else {
const truncated: string = IcsUtil.rruleWithUntil(master.rrule, bounds.beforeThis);
await EventDb.setRruleForUid(context, master.calKey, master.uid, truncated);
// ⚠️ 若用户编辑的正是"主事件这一行",target(= this.event)已被 doSave 写成新值,
// 而它属于被截断的旧系列 → 必须还原为原值,否则旧系列末尾会多出一条被改过的幽灵行。
if (cur.rrule !== '') {
await EventDb.restoreRowById(context, master.id, cur);
}
}
}
private async save(): Promise<void> {
if (this.isSaving) {
return;
}
// 便于诊断:记录这次保存走了哪条分支(标题已脱敏)
LogUtil.write(`保存判定:isExisting=${this.isExisting} recurring=${this.original !== null ? this.original.recurring : '?'} rrule=${this.original !== null && this.original.rrule !== '' ? '有' : '无'} 选本=${this.books.length} chosenKey=${this.chosenKey}`);
// ⭐ 重复日程:**先让用户选择范围(仅这一次 / 此次及以后 / 全部)**,再校验与落库。
// 必须放在最前面 —— 与"删除"路径保持一致。若把校验/日历本检查放在前面,
// 任一条件不满足就会提前 return,用户看到的只是表单底部一行小字,
// 永远等不到范围选择弹层(表现为"编辑重复日程没有那三个选项")。
if (this.isSeriesOccurrence()) {
this.pendingDelete = false;
this.sheetMode = 'scope';
this.sheetShow = true;
LogUtil.write('保存判定:重复系列 → 弹出范围选择');
return;
}
if (!this.validate()) {
LogUtil.write(`保存判定:校验未通过(${this.statusMsg})`);
return; return;
} }
// 日程必须落在某个 CalDAV 日历本上("本机"不再是可选目标)——没选到就直接拦下 // 日程必须落在某个 CalDAV 日历本上("本机"不再是可选目标)——没选到就直接拦下
const book = this.chosenBook(); const book = this.chosenBook();
if (book === null) {
this.statusMsg = this.books.length === 0
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
: '请选择要同步到的 CalDAV 日历本';
LogUtil.write(`保存判定:日历本解析失败(选本=${this.books.length} chosenKey=${this.chosenKey})`);
return;
}
await this.doSave('all');
}
/** 真正执行保存(scope 已被用户确认) */
private async doSave(scope: string): Promise<void> {
if (this.isSaving) {
return;
}
// ⭐ 范围确认后回到这里再校验一次:范围弹层打开时并未跳过校验,
// 只是把它推迟到"选完范围"之后(失败时弹层已关闭,错误显示在表单状态行)。
if (!this.validate()) {
return;
}
const book = this.chosenBook();
if (book === null) { if (book === null) {
this.statusMsg = this.books.length === 0 this.statusMsg = this.books.length === 0
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号' ? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
@@ -276,7 +579,8 @@ struct EventEditPage {
e.remotePath = encodeURIComponent(e.uid) + '.ics'; e.remotePath = encodeURIComponent(e.uid) + '.ics';
await EventDb.insertLocal(context, e); await EventDb.insertLocal(context, e);
} else { } else {
await EventDb.updateLocal(context, e); // 重复系列的"仅此次/此次及以后"需要特殊落库(EXDATE / UNTIL / 拆分)
await this.applySeriesEdit(context, e, false, scope);
} }
// 立即推送(尽力而为,失败不打断,下次同步会再推) // 立即推送(尽力而为,失败不打断,下次同步会再推)
if (e.href !== '') { if (e.href !== '') {
@@ -290,7 +594,7 @@ struct EventEditPage {
await SyncEngine.settleLocalEvents(context); await SyncEngine.settleLocalEvents(context);
} }
// 日志仅记录 uid 与提醒/重复设置,不落盘日程标题(避免隐私内容进入可备份的 sync.log) // 日志仅记录 uid 与提醒/重复设置,不落盘日程标题(避免隐私内容进入可备份的 sync.log)
LogUtil.write(`本地保存日程(标题已脱敏)uid=${e.uid} 提醒=${e.reminders.join('/')}分钟 重复=${e.rrule === '' ? '否' : e.rrule}`); LogUtil.write(`本地保存日程(标题已脱敏)uid=${e.uid} 范围=${scope} 提醒=${e.reminders.join('/')}分钟 重复=${e.rrule === '' ? '否' : e.rrule}`);
// 设置了提醒 → 此刻才申请通知权限(用户刚主动使用了依赖通知的功能, // 设置了提醒 → 此刻才申请通知权限(用户刚主动使用了依赖通知的功能,
// 符合《审核指南》7.17"在用户主动点击对应功能时申请",不随 App 启动自动弹窗) // 符合《审核指南》7.17"在用户主动点击对应功能时申请",不随 App 启动自动弹窗)
if (e.reminders.length > 0) { if (e.reminders.length > 0) {
@@ -344,6 +648,13 @@ struct EventEditPage {
if (this.event === null || this.isSaving) { if (this.event === null || this.isSaving) {
return; return;
} }
// ⭐ 重复日程:先让用户选择"删这一次 / 此次及以后 / 全部"
if (this.isSeriesOccurrence()) {
this.pendingDelete = true;
this.sheetMode = 'scope';
this.sheetShow = true;
return;
}
const label: string = this.title.trim() === '' ? '该日程' : `「${this.title.trim()}」`; const label: string = this.title.trim() === '' ? '该日程' : `「${this.title.trim()}」`;
this.getUIContext().showAlertDialog({ this.getUIContext().showAlertDialog({
title: '删除日程', title: '删除日程',
@@ -358,30 +669,66 @@ struct EventEditPage {
value: '删除', value: '删除',
fontColor: $r('app.color.error'), fontColor: $r('app.color.error'),
action: (): void => { action: (): void => {
this.removeEvent(); this.removeEvent('all');
} }
} }
}); });
} }
private async removeEvent(): Promise<void> { /**
* ⭐ 按范围删除:one = 仅此次(EXDATE 排除该次发生)/ future = 此次及以后(截断 RRULE)/
* all = 删除全部(DELETE 整个系列资源)。
*/
private async removeEvent(scope: string): Promise<void> {
if (this.event === null || this.isSaving) { if (this.event === null || this.isSaving) {
return; return;
} }
if (this.event !== null && this.event.recurring && this.event.rrule === '') {
this.statusMsg = '重复日程的单次删除暂不支持,请到服务器端调整重复规则';
return;
}
this.isSaving = true; this.isSaving = true;
try { try {
const context = this.getUIContext().getHostContext(); const context = this.getUIContext().getHostContext();
if (context === undefined) { if (context === undefined) {
return; return;
} }
await EventDb.markDeleted(context, this.event.id); const cur: LocalEvent = this.original ?? this.event;
if (this.event.href !== '') { const isSeries: boolean = this.isSeriesOccurrence();
if (!isSeries || scope === 'all') {
// 普通日程 / 删除全部:整行标记删除(系列删除由 pushSeries 处理成 DELETE 资源)
await EventDb.markDeleted(context, cur.id);
} else if (scope === 'one') {
// 仅此次:主事件加 EXDATE 排除该次;若该行本身就是覆盖实例 → 直接删掉这一行
if (cur.rrule === '' && cur.recurrenceId > 0) {
await EventDb.markDeleted(context, cur.id);
} else {
const master: LocalEvent | null = await this.loadMaster(context);
if (master !== null) {
const exList: string[] = master.exdate === '' ? [] : master.exdate.split(';');
const occIcs: string = IcsUtil.fmtIcsTime(this.occStartMs, master.isAllDay);
if (!exList.includes(occIcs)) {
exList.push(occIcs);
}
await EventDb.setExdate(context, master.id, exList.join(';'));
// 若该次此前被单独改写过(存在覆盖行),一并清掉
const rows: LocalEvent[] = await EventDb.queryByUid(context, cur.calKey, cur.uid);
for (const r of rows) {
if (r.rrule === '' && r.recurrenceId > 0) {
await EventDb.markDeleted(context, r.id);
}
}
}
}
} else {
// 此次及以后:主事件 RRULE 截断到"此次之前";该次及以后的本地行物理删除
const master: LocalEvent | null = await this.loadMaster(context);
if (master !== null) {
const bounds: SeriesBounds = await this.seriesBoundaries(context);
const truncated: string = IcsUtil.rruleWithUntil(master.rrule, bounds.beforeThis);
await EventDb.setRruleForUid(context, master.calKey, master.uid, truncated);
await EventDb.purgeByUidFrom(context, master.calKey, master.uid, bounds.thisOcc);
}
}
if (cur.href !== '') {
const accounts: DavAccount[] = await AccountStore.loadAll(context); const accounts: DavAccount[] = await AccountStore.loadAll(context);
const acc = accounts.find((a: DavAccount): boolean => a.calendarHrefs.includes(this.event?.href ?? '')); const acc = accounts.find((a: DavAccount): boolean => a.calendarHrefs.includes(cur.href));
if (acc !== undefined) { if (acc !== undefined) {
const auth: string = DavClient.authHeader(acc.username, acc.password); const auth: string = DavClient.authHeader(acc.username, acc.password);
await SyncEngine.pushDirtyForAccount(context, acc, auth); await SyncEngine.pushDirtyForAccount(context, acc, auth);
@@ -390,7 +737,7 @@ struct EventEditPage {
await SyncEngine.settleLocalEvents(context); await SyncEngine.settleLocalEvents(context);
} }
// 日志不落盘日程标题,仅以 uid 追踪 // 日志不落盘日程标题,仅以 uid 追踪
LogUtil.write(`本地删除日程(标题已脱敏)uid=${this.event?.uid ?? ''}`); LogUtil.write(`本地删除日程(标题已脱敏)uid=${cur.uid} 范围=${scope}`);
// 立即刷新提醒(取消已发布但日程已删的提醒) // 立即刷新提醒(取消已发布但日程已删的提醒)
try { try {
await ReminderService.refreshReminders(context as common.UIAbilityContext); await ReminderService.refreshReminders(context as common.UIAbilityContext);
@@ -651,14 +998,93 @@ struct EventEditPage {
.backgroundColor($r('app.color.page_bg')) .backgroundColor($r('app.color.page_bg'))
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件) // 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.bindSheet($$this.pickerShow, this.dateTimePickerSheet(), { // ⚠️ 整个页面**只允许一个 bindSheet**:挂两个时后者会覆盖前者,
height: 380, // 表现为"时间选择框白框一闪而过"(范围弹层顶掉了它)。
// 这里用 sheetMode 在同一个弹层里切换内容。
.bindSheet($$this.sheetShow, this.bottomSheet(), {
height: this.sheetMode === 'scope' ? 320 : 380,
showClose: false, showClose: false,
dragBar: true, dragBar: true,
backgroundColor: $r('app.color.card_bg') backgroundColor: $r('app.color.card_bg')
}) })
} }
/** ⭐ 唯一的底部弹层:按 sheetMode 切换显示"时间选择"或"重复范围选择" */
@Builder
bottomSheet() {
if (this.sheetMode === 'scope') {
this.scopeSheet()
} else {
this.dateTimePickerSheet()
}
}
/** ⭐ 重复日程"作用范围"选择弹层:保存/删除前让用户明确改哪几次 */
@Builder
scopeSheet() {
Column({ space: 8 }) {
Text(this.pendingDelete ? '删除重复日程' : '修改重复日程')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 14, bottom: 4 })
Text('这是一个重复日程,请选择作用的范围')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.textAlign(TextAlign.Center)
.padding({ bottom: 6 })
this.scopeOption('one', this.pendingDelete ? '仅删除这一次' : '仅修改这一次',
'只影响所选日期的这一次,其余保持不变')
this.scopeOption('future', this.pendingDelete ? '删除此次及以后' : '修改此次及以后',
'从这一次开始到最后一次都' + (this.pendingDelete ? '删除' : '修改'))
this.scopeOption('all', this.pendingDelete ? '删除全部' : '修改全部',
'整条重复日程的所有发生都' + (this.pendingDelete ? '删除' : '修改'))
Text('取消')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 12, bottom: 16 })
.onClick(() => {
this.sheetShow = false;
})
}
.width('100%')
.padding({ left: 20, right: 20 })
}
@Builder
scopeOption(mode: string, title: string, desc: string) {
Column({ space: 3 }) {
Text(title)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.width('100%')
.textAlign(TextAlign.Start)
Text(desc)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.textAlign(TextAlign.Start)
}
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
.borderRadius(12)
.backgroundColor($r('app.color.chip_off_bg'))
.onClick(() => {
this.sheetShow = false;
if (this.pendingDelete) {
this.removeEvent(mode);
} else {
this.doSave(mode);
}
})
}
/** /**
* 页面转场:淡入淡出。 * 页面转场:淡入淡出。
* 不做左右平移 / 上下位移(不调用 .slide() / .translate()),只做透明度变化, * 不做左右平移 / 上下位移(不调用 .slide() / .translate()),只做透明度变化,
@@ -808,7 +1234,7 @@ struct EventEditPage {
.backgroundColor($r('app.color.brand')) .backgroundColor($r('app.color.brand'))
.onClick(() => { .onClick(() => {
this.applyPicker(); this.applyPicker();
this.pickerShow = false; this.sheetShow = false;
}) })
} }
.width('100%') .width('100%')
@@ -852,6 +1278,45 @@ class CalendarDataBridge {
} }
return result; return result;
} }
/**
* ⭐ 按 calKey(必要时回退按 href 匹配)解析一个日历本的基本信息,
* 用于"编辑既有日程时,它的日历本不在可写清单里"的兜底补全。
* 找不到时返回 null(界面会给出明确提示,不静默失败)。
*/
static async lookupBook(context: common.Context, calKey: string,
href: string): Promise<BookLookup | null> {
const accounts: DavAccount[] = await AccountStore.loadAll(context);
for (const acc of accounts) {
for (let i = 0; i < acc.calendarHrefs.length; i++) {
const key: string = `${acc.id}_${i}`;
if (key !== calKey && !(href !== '' && acc.calendarHrefs[i] === href)) {
continue;
}
const out = new BookLookup();
out.calKey = key;
out.href = acc.calendarHrefs[i];
let name: string = i < acc.calendarNames.length ? acc.calendarNames[i] : '';
if (name === '') {
name = acc.calendarHrefs.length === 1 ? acc.name : `日历本 ${i + 1}`;
}
out.name = `${acc.name} · ${name}`;
let color: string = i < acc.calendarColors.length
? AccountStore.normalizeColor(acc.calendarColors[i]) : '';
out.color = color !== '' ? color : BookPalette.colorForHref(acc.calendarHrefs[i]);
return out;
}
}
return null;
}
}
/** lookupBook 的返回值(命名 class:ArkTS 不许用内联对象字面量类型) */
class BookLookup {
calKey: string = '';
href: string = '';
name: string = '';
color: string = '#007DFF';
} }
class CalSourceWithHref extends CalSource { class CalSourceWithHref extends CalSource {
+68 -38
View File
@@ -1,13 +1,13 @@
// entry/src/main/ets/pages/Index.ets // entry/src/main/ets/pages/Index.ets
// 同步日历主界面:月视图(左右滑动翻月)/ 周视图 / 日视图 / 日程列表 // 同步日历主界面:月视图(左右滑动翻月)/ 周视图 / 日视图 / 日程列表
// 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天" // 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天"
import { curves, mediaquery, router } from '@kit.ArkUI'; import { curves, display, router, window } from '@kit.ArkUI';
import { common, abilityAccessCtrl, bundleManager } from '@kit.AbilityKit'; import { common, abilityAccessCtrl, bundleManager } from '@kit.AbilityKit';
import { geoLocationManager } from '@kit.LocationKit'; import { geoLocationManager } from '@kit.LocationKit';
import { BusinessError, pasteboard } from '@kit.BasicServicesKit'; import { BusinessError, pasteboard } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore'; import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore';
import { DisplayEvent, CalendarDataService } from '../common/CalendarDataService'; import { DisplayEvent, CalendarDataService } from '../common/CalendarDataService';
import { SyncEngine } from '../common/SyncEngine'; import { SyncEngine, SyncBusyError } from '../common/SyncEngine';
import { LogUtil } from '../common/LogUtil'; import { LogUtil } from '../common/LogUtil';
import { LunarUtil } from '../common/LunarUtil'; import { LunarUtil } from '../common/LunarUtil';
import { CardDataService } from '../common/CardDataService'; import { CardDataService } from '../common/CardDataService';
@@ -54,13 +54,16 @@ struct Index {
@State syncing: boolean = false; @State syncing: boolean = false;
@State loading: boolean = true; @State loading: boolean = true;
@State menuOpen: boolean = false; // 顶部 ≡ 下拉菜单 @State menuOpen: boolean = false; // 顶部 ≡ 下拉菜单
// 宽屏(平板 / 折叠屏展开态):月视图切左右双栏(左月历 / 右当日日程)。 // 宽屏(双栏:月视图左月历 / 右当日日程,周视图同理)判据,两条满足其一即双栏:
// 判据是窗口**短边**是否达到大屏断点 840vp:短边在旋转时不变(宽高互换), // ① 窗口短边 ≥ 840vp —— 折叠屏展开态 / 平板这类"大屏"。短边在旋转时不变,
// 所以横竖屏布局天然保持一致 —— 满足"屏幕比例接近 1:1 的设备,横竖屏布局需保持一致"; // 故横竖屏布局天然一致,满足"屏幕比例接近 1:1 的设备,横竖屏布局需保持一致";
// 同时不锁死屏幕方向,折叠屏展开态与平板仍可自由横竖屏切换。 // ② 横屏且宽高比 ≥ 1.5 —— 长条形屏幕(普通手机、折叠屏**合上后的外屏**)。
// 这类设备比例远离 1:1,旋转应当切换布局(0.0.2 起即如此,用户已习惯)。
// 加比例下限是为了兜底"短边不足 840 但比例接近 1:1"的小方屏:旋转依旧不变。
// 2026-09-29 修复:上一版只留了判据 ①,导致 Mate X7 合盖后旋转屏幕布局不再变化。
@State wideLayout: boolean = false; @State wideLayout: boolean = false;
private widthListener: mediaquery.MediaQueryListener | null = null; private winObj: window.Window | null = null;
private heightListener: mediaquery.MediaQueryListener | null = null; private winSizeCallback: ((size: window.Size) => void) | null = null;
private accounts: DavAccount[] = []; private accounts: DavAccount[] = [];
private sources: CalSource[] = []; private sources: CalSource[] = [];
private swiperController: SwiperController = new SwiperController(); private swiperController: SwiperController = new SwiperController();
@@ -147,47 +150,57 @@ struct Index {
clearInterval(this.nowTimer); clearInterval(this.nowTimer);
this.nowTimer = -1; this.nowTimer = -1;
} }
if (this.widthListener !== null) { if (this.winObj !== null && this.winSizeCallback !== null) {
this.widthListener.off('change'); try {
this.widthListener = null; this.winObj.off('windowSizeChange', this.winSizeCallback);
} catch (err) {
// 窗口可能已销毁(页面退出),忽略
} }
if (this.heightListener !== null) { this.winSizeCallback = null;
this.heightListener.off('change'); this.winObj = null;
this.heightListener = null;
} }
} }
/** /**
* 宽屏判据:窗口短边 min(宽, 高) 是否达到大屏断点 840vp。 * 由窗口实际尺寸(px → vp)计算单栏/双栏。判据见 wideLayout 字段注释:
* min(w,h) >= 840vp 等价于 (width >= 840vp) && (height >= 840vp),用两条 mediaquery 合成。 * 短边 ≥ 840vp(大屏,旋转不变)或 横屏且宽高比 ≥ 1.5(长条形屏幕,旋转切换)。
*
* 为什么用"短边"而不是"是否横屏":
* 1) 旋转时短边不变 → 横竖屏布局自动保持一致。屏幕比例接近 1:1 的设备(折叠屏展开态、
* 平板)在旋转时不会出现"单栏 ⇄ 双栏"的布局跳变;
* 2) 不锁死屏幕方向,仍支持折叠屏展开态与平板的横竖屏自由切换显示。
*/ */
private applyLayoutMetrics(): void { private applyLayoutFromWindow(win: window.Window): void {
let wOk: boolean = false; try {
let hOk: boolean = false; const rect = win.getWindowProperties().windowRect;
if (this.widthListener !== null) { const density: number = display.getDefaultDisplaySync().densityPixels;
wOk = this.widthListener.matches; if (density <= 0) {
return;
} }
if (this.heightListener !== null) { const w: number = rect.width / density;
hOk = this.heightListener.matches; const h: number = rect.height / density;
const shortEdge: number = Math.min(w, h);
const ratio: number = h > 0 ? w / h : 1;
const wide: boolean = shortEdge >= 840 || (w > h && ratio >= 1.5);
LogUtil.write(`窗口尺寸 ${Math.round(w)}x${Math.round(h)}vp(短边 ${Math.round(shortEdge)},宽高比 ${ratio.toFixed(2)})→ ${wide ? '双栏' : '单栏'}`);
if (wide !== this.wideLayout) {
this.wideLayout = wide;
}
} catch (err) {
// 取窗口信息失败时保持现状(罕见:窗口已销毁)
} }
this.wideLayout = wOk && hOk;
} }
private initLayoutListener(): void { private initLayoutListener(): void {
const mq = this.getUIContext().getMediaQuery(); const ctx = this.getUIContext().getHostContext();
this.widthListener = mq.matchMediaSync('(width >= 840vp)'); if (ctx === undefined) {
this.heightListener = mq.matchMediaSync('(height >= 840vp)'); return;
this.applyLayoutMetrics(); }
this.widthListener.on('change', (): void => { window.getLastWindow(ctx).then((win: window.Window): void => {
this.applyLayoutMetrics(); this.winObj = win;
}); this.applyLayoutFromWindow(win);
this.heightListener.on('change', (): void => { this.winSizeCallback = (_size: window.Size): void => {
this.applyLayoutMetrics(); // size 参数为 px;重新读取属性并换算(折叠/展开、旋转都会触发)
this.applyLayoutFromWindow(win);
};
win.on('windowSizeChange', this.winSizeCallback);
}).catch((err: BusinessError): void => {
LogUtil.write(`窗口尺寸监听初始化失败(保持默认单栏):${err.message}`);
}); });
} }
@@ -571,6 +584,12 @@ struct Index {
this.getUIContext().getPromptAction() this.getUIContext().getPromptAction()
.showToast({ message: `「${acc.name}」同步完成` }); .showToast({ message: `「${acc.name}」同步完成` });
} catch (err) { } catch (err) {
// ⭐ 已有同步在进行中 → 提示"请耐心等待",不报"失败"
if (err instanceof SyncBusyError) {
this.getUIContext().getPromptAction()
.showToast({ message: '有同步任务正在进行中,请耐心等待' });
return;
}
const e = err as BusinessError; const e = err as BusinessError;
this.getUIContext().getPromptAction() this.getUIContext().getPromptAction()
.showToast({ message: `同步失败:${e.message}` }); .showToast({ message: `同步失败:${e.message}` });
@@ -620,6 +639,8 @@ struct Index {
await ScreenKeeper.acquire(context as common.UIAbilityContext); await ScreenKeeper.acquire(context as common.UIAbilityContext);
let ok: number = 0; let ok: number = 0;
let failMsg: string = ''; let failMsg: string = '';
// ⭐ 是否因为"已有同步在进行"而被跳过(这不算失败,只提示等待)
let busySkipped: boolean = false;
// 备份模式:先把系统本地日历导入目标日历本(幂等),再随正常同步推送上服务器 // 备份模式:先把系统本地日历导入目标日历本(幂等),再随正常同步推送上服务器
try { try {
await SystemCalendarImport.importIfNeeded(context); await SystemCalendarImport.importIfNeeded(context);
@@ -641,6 +662,11 @@ struct Index {
acc.lastSyncTime = this.formatNow(); acc.lastSyncTime = this.formatNow();
ok++; ok++;
} catch (err) { } catch (err) {
// ⭐ 并发被互斥锁拦下:不是失败,只是"已有同步在跑",稍后自动同步会覆盖,直接略过
if (err instanceof SyncBusyError) {
busySkipped = true;
continue;
}
const e = err as BusinessError; const e = err as BusinessError;
failMsg = e.message; failMsg = e.message;
} }
@@ -671,6 +697,10 @@ struct Index {
if (failMsg !== '') { if (failMsg !== '') {
this.getUIContext().getPromptAction() this.getUIContext().getPromptAction()
.showToast({ message: ok > 0 ? `同步完成 ${ok} 个,失败:${failMsg}` : `同步失败:${failMsg}` }); .showToast({ message: ok > 0 ? `同步完成 ${ok} 个,失败:${failMsg}` : `同步失败:${failMsg}` });
} else if (busySkipped && ok === 0) {
// 全部账号都因"已有同步在进行"被跳过 → 只提示等待,不说失败
this.getUIContext().getPromptAction()
.showToast({ message: '有同步任务正在进行中,请耐心等待' });
} else if (ok > 0 && manual) { } else if (ok > 0 && manual) {
this.getUIContext().getPromptAction() this.getUIContext().getPromptAction()
.showToast({ message: `同步完成(${ok} 个账号)` }); .showToast({ message: `同步完成(${ok} 个账号)` });
+12 -1
View File
@@ -682,9 +682,20 @@ struct SettingsPage {
return; return;
} }
try { try {
// ⭐ 账号库整体重置 = 所有账号都没了 → 系统日历里的全部镜像账户也应一并清除
// (否则会留下一堆无主的幽灵日历本,且再无人能对应回账号)。
// 未授权/无镜像时返回 0,不影响重置本身。
let mirrored: number = 0;
try {
mirrored = await SystemCalendarMirror.removeAll(this.context);
} catch (err) {
// 清理失败不阻断重置
}
await AccountStore.resetVault(this.context); await AccountStore.resetVault(this.context);
this.getUIContext().getPromptAction().showToast({ this.getUIContext().getPromptAction().showToast({
message: '账号库已重置,请重新添加账号' message: mirrored > 0
? `账号库已重置,并清除系统日历中 ${mirrored} 个镜像日历本`
: '账号库已重置,请重新添加账号'
}); });
} catch (err) { } catch (err) {
this.getUIContext().getPromptAction().showToast({ this.getUIContext().getPromptAction().showToast({
+9 -17
View File
@@ -110,41 +110,33 @@ struct Widget4x4Card {
return r >= 0 && Math.floor(r * 24) === h; return r >= 0 && Math.floor(r * 24) === h;
} }
/** 视窗范围:最早日程 ~ 最晚日程结束(今天再并入当前时刻)。 /** 视窗范围:**8:00~18:00 恒显示**;仅当 8 点前 / 18 点后有日程时才向该侧扩展。
* 不做"最大跨度截断" —— 现在可以滑动了,没必要砍掉日程。 */ * (与 App 内时间轴 TimelineUtil.viewRange 同一口径,避免两处漂移。) */
private w4Range(): W4Range { private w4Range(): W4Range {
const rg = new W4Range(); const rg = new W4Range();
const bs: TBlock[] = this.parseBlocks(); const bs: TBlock[] = this.parseBlocks();
let s: number = -1; let s: number = 8;
let e: number = -1; let e: number = 18;
for (const b of bs) { for (const b of bs) {
const bh: number = Math.floor(b.topRatio * 24); const bh: number = Math.floor(b.topRatio * 24);
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001); const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
if (s < 0 || bh < s) { if (bh < s) {
s = bh; s = bh;
} }
if (e < 0 || eh > e) { if (eh > e) {
e = eh; e = eh;
} }
} }
const r: number = this.w4NowR(); const r: number = this.w4NowR();
// 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示); // 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示);
// 定时日程已全部结束 → 不并入 → 时间刻度直接截断到最晚日程 // 且仅在该侧本来就有日程时才允许继续向外扩(不因当前时刻把 8 点前/18 点后空档重新显示)
const nh: number = (r >= 0 && !this.w4AllTimedEnded()) ? Math.floor(r * 24) : -1; const nh: number = (r >= 0 && !this.w4AllTimedEnded()) ? Math.floor(r * 24) : -1;
if (s < 0) { if (nh >= 0 && nh < s && s < 8) {
s = nh >= 0 ? nh : 8;
}
if (e < 0) {
e = nh >= 0 ? nh + 1 : 20;
}
if (nh >= 0) {
if (nh < s) {
s = nh; s = nh;
} }
if (nh + 1 > e) { if (nh >= 0 && nh + 1 > e && e > 18) {
e = nh + 1; e = nh + 1;
} }
}
if (s < 0) { if (s < 0) {
s = 0; s = 0;
} }
+9 -17
View File
@@ -109,41 +109,33 @@ struct Widget6x4Card {
return r >= 0 && Math.floor(r * 24) === h; return r >= 0 && Math.floor(r * 24) === h;
} }
/** 视窗范围:最早日程 ~ 最晚日程结束(今天再并入当前时刻)。 /** 视窗范围:**8:00~18:00 恒显示**;仅当 8 点前 / 18 点后有日程时才向该侧扩展。
* 不做"最大跨度截断" —— 现在可以滑动了,没必要砍掉日程。 */ * (与 App 内时间轴 TimelineUtil.viewRange 同一口径,避免两处漂移。) */
private w6Range(): W6Range { private w6Range(): W6Range {
const rg = new W6Range(); const rg = new W6Range();
const bs: TBlock6[] = this.parseBlocks(); const bs: TBlock6[] = this.parseBlocks();
let s: number = -1; let s: number = 8;
let e: number = -1; let e: number = 18;
for (const b of bs) { for (const b of bs) {
const bh: number = Math.floor(b.topRatio * 24); const bh: number = Math.floor(b.topRatio * 24);
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001); const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
if (s < 0 || bh < s) { if (bh < s) {
s = bh; s = bh;
} }
if (e < 0 || eh > e) { if (eh > e) {
e = eh; e = eh;
} }
} }
const r: number = this.w6NowR(); const r: number = this.w6NowR();
// 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示); // 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示);
// 定时日程已全部结束 → 不并入 → 时间刻度直接截断到最晚日程 // 且仅在该侧本来就有日程时才允许继续向外扩(不因当前时刻把 8 点前/18 点后空档重新显示)
const nh: number = (r >= 0 && !this.w6AllTimedEnded()) ? Math.floor(r * 24) : -1; const nh: number = (r >= 0 && !this.w6AllTimedEnded()) ? Math.floor(r * 24) : -1;
if (s < 0) { if (nh >= 0 && nh < s && s < 8) {
s = nh >= 0 ? nh : 8;
}
if (e < 0) {
e = nh >= 0 ? nh + 1 : 20;
}
if (nh >= 0) {
if (nh < s) {
s = nh; s = nh;
} }
if (nh + 1 > e) { if (nh >= 0 && nh + 1 > e && e > 18) {
e = nh + 1; e = nh + 1;
} }
}
if (s < 0) { if (s < 0) {
s = 0; s = 0;
} }