3 Commits
Author SHA1 Message Date
laoyang 29898d5dff 修复:改了日历本颜色,系统日历镜像颜色不跟变
根因:SyncEngine.refreshCalendarColors 每轮同步都 PROPFIND 服务器把新
calendar-color 写进**内存**里的账号对象,但从不 saveAll(唯一落盘点是
removeDeadBooks 的"服务器删本"分支)。而镜像 listBooks 是重新
AccountStore.loadAll 从磁盘读 → 拿到的永远是旧颜色 → ensureCalendar
setConfig(旧色) → 系统日历颜色永远不变。
用户在「账号设置」页看到的是**当场 PROPFIND** 的新颜色,所以表现为
"设置页颜色对、系统日历颜色错"。

修法:
1) refreshCalendarColors 返回 boolean(颜色/写权限是否有实际变化,
   含数组长度补齐);syncAccountInner 在有变化时立刻 loadAll+saveAll
   落盘(同 removeDeadBooks 的既有模式),镜像同轮即可读到新色;
2) SystemCalendarMirror.ensureCalendar 的 setConfig 失败由静默改为
   写日志(新建/复用两处),"颜色不变"从此可排查。

验证:assembleHap BUILD SUCCESSFUL;已 install -r 到真机。
2026-10-21 07:11:23 +08:00
laoyang c711cdb7d5 版本号 0.0.8 -> 0.0.9(versionCode 900) 2026-10-20 22:34:12 +08:00
laoyang 2f124eada0 镜像到系统日历:新增「全选/全不选」日历本
设置页镜像勾选网格上方加一行「全选(已选 x/N)」:
· 整行可点;勾选框半选时显示为关,点按 = 全选,再次点按 = 全不选;
· 全选:一次性把全部候选本(已排除"系统日历备份目标本"防回灌)写入
  mirror_book_ids —— preferences 只落盘一次、提醒缓存只失效一次,
  与逐个 toggle 的关键区别;
· 全不选:逐本 removeById 删对应系统日历账户(与取消单个勾选同语义),
  逐个独立 try,失败不影响其余;
· onChange 加同值守卫,过滤系统重复回调导致的二次翻转。

验证:assembleHap BUILD SUCCESSFUL;已 install -r 到真机。
2026-10-20 22:31:29 +08:00
4 changed files with 114 additions and 8 deletions
+2 -2
View File
@@ -2,8 +2,8 @@
"app": { "app": {
"bundleName": "synccalendar.yangyq.net", "bundleName": "synccalendar.yangyq.net",
"vendor": "yangyq", "vendor": "yangyq",
"versionCode": 800, "versionCode": 900,
"versionName": "0.0.8", "versionName": "0.0.9",
"buildVersion": "1", "buildVersion": "1",
"icon": "$media:layered_image", "icon": "$media:layered_image",
"label": "$string:app_name" "label": "$string:app_name"
+33 -4
View File
@@ -122,7 +122,23 @@ export class SyncEngine {
private static async syncAccountInner(context: common.Context, acc: DavAccount): Promise<number> { private static async syncAccountInner(context: common.Context, acc: DavAccount): Promise<number> {
const auth: string = DavClient.authHeader(acc.username, acc.password); const auth: string = DavClient.authHeader(acc.username, acc.password);
// 0) 刷新服务器端日历本颜色(每次同步都校正) // 0) 刷新服务器端日历本颜色(每次同步都校正)
await SyncEngine.refreshCalendarColors(acc, auth); // ⭐ 有变化就立刻落盘:镜像 listBooks 从磁盘重新 loadAll,
// 不落盘的话镜像 setConfig 的永远是旧颜色(系统日历颜色不跟服务器变)。
const colorChanged: boolean = await SyncEngine.refreshCalendarColors(acc, auth);
if (colorChanged) {
try {
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);
LogUtil.write('日历本颜色/权限有变化,已落盘(镜像将使用新颜色)');
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`颜色/权限落盘失败(不影响同步): ${e.message}`);
}
}
// 1) 推送该账号日历本下的本地修改 // 1) 推送该账号日历本下的本地修改
await SyncEngine.pushDirtyForAccount(context, acc, auth); await SyncEngine.pushDirtyForAccount(context, acc, auth);
// 2) 拉取远端变更:REPORT 仅取 etag;新增/变化的资源用 GET 补拉完整 ICS。 // 2) 拉取远端变更:REPORT 仅取 etag;新增/变化的资源用 GET 补拉完整 ICS。
@@ -363,8 +379,13 @@ export class SyncEngine {
/** /**
* 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors * 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors
* 失败静默(颜色不影响数据正确性) * 失败静默(颜色不影响数据正确性)
* ⭐ 返回**是否有实际变化**(颜色或写权限)——调用方据此落盘。
* (0.0.9 修复:此前变化只留在内存、从不 saveAll ⇒ 镜像 listBooks 从磁盘读到的
* 永远是旧颜色 ⇒ setConfig 旧色 ⇒ 系统日历颜色永远不跟服务器变;
* 而账号设置页是当场 PROPFIND 的,所以"页面上是新色、系统日历是旧色"。)
*/ */
static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<void> { static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<boolean> {
let changed: boolean = false;
let entries: DavColorEntry[] = []; let entries: DavColorEntry[] = [];
try { try {
entries = await DavClient.propfindColors(acc.serverUrl, auth); entries = await DavClient.propfindColors(acc.serverUrl, auth);
@@ -382,9 +403,11 @@ export class SyncEngine {
if (found !== undefined && found.color !== '') { if (found !== undefined && found.color !== '') {
while (acc.calendarColors.length <= i) { while (acc.calendarColors.length <= i) {
acc.calendarColors.push(''); acc.calendarColors.push('');
changed = true; // 长度补齐也视作变化(旧账号可能还没这一位)
} }
if (acc.calendarColors[i] !== found.color) { if (acc.calendarColors[i] !== found.color) {
LogUtil.write(`日历本[${i}] 颜色更新:${acc.calendarColors[i] === '' ? '(空)' : acc.calendarColors[i]} → ${found.color}`); LogUtil.write(`日历本[${i}] 颜色更新:${acc.calendarColors[i] === '' ? '(空)' : acc.calendarColors[i]} → ${found.color}`);
changed = true;
} }
acc.calendarColors[i] = found.color; acc.calendarColors[i] = found.color;
} else if (found === undefined) { } else if (found === undefined) {
@@ -396,17 +419,23 @@ export class SyncEngine {
acc.calendarWritable.push('1'); acc.calendarWritable.push('1');
} }
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`; const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
let writable: string;
if (found !== undefined && found.privilegeKnown) { if (found !== undefined && found.privilegeKnown) {
// 服务器声明了权限:直接采用 // 服务器声明了权限:直接采用
acc.calendarWritable[i] = found.writable ? '1' : '0'; writable = found.writable ? '1' : '0';
LogUtil.write(`日历本[${i}]「${calName}」写权限(privilege):${found.writable ? '可写' : '只读'}`); LogUtil.write(`日历本[${i}]「${calName}」写权限(privilege):${found.writable ? '可写' : '只读'}`);
} else { } else {
// 服务器未声明权限(如部分 Synology 配置)或 PROPFIND 未匹配到该本:真实写探测 // 服务器未声明权限(如部分 Synology 配置)或 PROPFIND 未匹配到该本:真实写探测
const w: boolean = await DavClient.probeWritable(target, auth); const w: boolean = await DavClient.probeWritable(target, auth);
acc.calendarWritable[i] = w ? '1' : '0'; writable = w ? '1' : '0';
LogUtil.write(`日历本[${i}]「${calName}」写权限(探测):${w ? '可写' : '只读'}`); LogUtil.write(`日历本[${i}]「${calName}」写权限(探测):${w ? '可写' : '只读'}`);
} }
if (acc.calendarWritable[i] !== writable) {
changed = true;
} }
acc.calendarWritable[i] = writable;
}
return changed;
} }
/** Map<key, 行数组> 追加一行(没有该 key 就建数组) */ /** Map<key, 行数组> 追加一行(没有该 key 就建数组) */
@@ -1146,7 +1146,9 @@ export class SystemCalendarMirror {
try { try {
await cal.setConfig({ enableReminder: true, color: ref.color }); await cal.setConfig({ enableReminder: true, color: ref.color });
} catch (err) { } catch (err) {
// 设色失败不影响镜像本身 // 设色失败不影响镜像本身,但要留痕(否则"颜色不变"无从排查)
const e = err as BusinessError;
LogUtil.write(`镜像账户设色失败 ${accountName}: ${e.message}`);
} }
return cal; return cal;
} }
@@ -1159,7 +1161,8 @@ export class SystemCalendarMirror {
try { try {
await cal.setConfig({ enableReminder: true, color: ref.color }); await cal.setConfig({ enableReminder: true, color: ref.color });
} catch (err) { } catch (err) {
// 同上 const e = err as BusinessError;
LogUtil.write(`镜像账户设色失败(新建) ${accountName}: ${e.message}`);
} }
return cal; return cal;
} }
+74
View File
@@ -293,6 +293,51 @@ struct SettingsPage {
return this.allBooks.length - this.mirrorCandidates.length; return this.allBooks.length - this.mirrorCandidates.length;
} }
/** 已勾选的可镜像本数量(mirrorKeys 里可能残留已不在候选里的 bookId,按候选口径统计) */
private mirrorSelectedCount(): number {
return this.mirrorCandidates.filter((b: BackupTarget): boolean =>
this.mirrorKeys.indexOf(b.bookId) >= 0).length;
}
/**
* 「全选 / 全不选」要镜像的日历本。
* ⭐ 批量落盘只写一次 preferences、缓存只失效一次(与逐个 toggle 的关键区别);
* 全不选时逐本删除对应系统日历账户(与取消单个勾选同一语义)。
* 半选状态下点按 = 全选。
*/
private async toggleMirrorAll(): Promise<void> {
if (this.context === undefined || this.mirrorCandidates.length === 0) {
return;
}
const selectAll: boolean = this.mirrorSelectedCount() < this.mirrorCandidates.length;
if (selectAll) {
const ids: string[] = this.mirrorCandidates.map((b: BackupTarget): string => b.bookId);
this.mirrorKeys = ids;
await AppSettings.setMirrorBookIds(this.context, ids);
this.getUIContext().getPromptAction().showToast({
message: `已全选 ${ids.length} 个日历本,点「立即执行镜像」写入系统日历`
});
} else {
// 逐本删除系统日历账户;逐个独立 try,失败不影响其余
for (const k of this.mirrorKeys) {
try {
await SystemCalendarMirror.removeById(this.context, k);
} catch (err) {
const e = err as BusinessError;
console.error(`全不选:删除镜像账户失败 bookId=${k}: ${e.message}`);
}
}
this.mirrorKeys = [];
await AppSettings.setMirrorBookIds(this.context, []);
this.getUIContext().getPromptAction().showToast({
message: '已取消全部勾选,并清除对应的系统日历账户'
});
}
// ⭐ 勾选本集合变化 → 提醒侧"已镜像本"缓存失效并重排提醒
ReminderService.invalidateMirrorCache();
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
}
/** 该日历本是否只读:服务器无写权限(探测结果)或用户手动标记只读 —— 与首页色块/只读标记同一口径 */ /** 该日历本是否只读:服务器无写权限(探测结果)或用户手动标记只读 —— 与首页色块/只读标记同一口径 */
private isBookReadonly(b: BackupTarget): boolean { private isBookReadonly(b: BackupTarget): boolean {
return !b.serverWritable || this.manualKeys.includes(b.calKey); return !b.serverWritable || this.manualKeys.includes(b.calKey);
@@ -978,6 +1023,35 @@ struct SettingsPage {
.fontColor($r('app.color.text_hint')) .fontColor($r('app.color.text_hint'))
.width('100%') .width('100%')
} }
// ⭐ 全选 / 全不选:半选状态下点按 = 全选
Row({ space: 6 }) {
Text(`全选(已选 ${this.mirrorSelectedCount()}/${this.mirrorCandidates.length})`)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(1)
Toggle({
type: ToggleType.Checkbox,
isOn: this.mirrorCandidates.length > 0
&& this.mirrorSelectedCount() === this.mirrorCandidates.length
})
.selectedColor($r('app.color.brand'))
.onChange((on: boolean): void => {
// 半选时勾选框显示为关,onChange(on=true) 走全选;全选时取消走全不选
if (on === (this.mirrorSelectedCount() === this.mirrorCandidates.length)) {
return; // 状态未变化(个别系统会重复回调),忽略
}
this.toggleMirrorAll();
})
}
.width('100%')
.padding({ top: 2, bottom: 2 })
.onClick(() => {
// 整行可点:直接按"是否已全选"翻转,比依赖 Checkbox 的 onChange 更可靠
this.toggleMirrorAll();
})
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) { Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(this.mirrorCandidates, (b: BackupTarget): void => { ForEach(this.mirrorCandidates, (b: BackupTarget): void => {
Row({ space: 6 }) { Row({ space: 6 }) {