修复:编辑重复日程缺范围选择;同步并发不再误报失败
问题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(不算失败),全部被跳过时也只提示等待
This commit is contained in:
@@ -734,6 +734,34 @@ export class EventDb {
|
|||||||
return await EventDb.insertLocal(context, e);
|
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`)。
|
||||||
|
|||||||
@@ -10,12 +10,49 @@ import { LogUtil } from './LogUtil';
|
|||||||
import { AppSettings } from './AppSettings';
|
import { AppSettings } from './AppSettings';
|
||||||
import { SystemCalendarMirror } from './SystemCalendarMirror';
|
import { SystemCalendarMirror } from './SystemCalendarMirror';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⭐ "已有同步任务正在进行"专用错误类型。
|
||||||
|
*
|
||||||
|
* 背景(用户反馈):后台同步进行中时,用户再点「手动同步」或在账号管理点「保存并同步」,
|
||||||
|
* 会并发触发第二轮同步;互斥锁把它拦下来,但那时抛的是普通 Error → 界面统一显示
|
||||||
|
* "同步失败:该账号正在同步中,请稍后再试"。用户看到"失败"会误以为出了问题。
|
||||||
|
*
|
||||||
|
* 正确做法:并发被拦下时**不是失败**,而应提示"有同步任务正在进行中,请耐心等待"。
|
||||||
|
* 因此用这个独立类型与真正的同步故障区分开,界面据此改提示语(而非报错)。
|
||||||
|
*/
|
||||||
|
export class SyncBusyError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('SYNC_BUSY');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class SyncEngine {
|
export class SyncEngine {
|
||||||
/** 正在同步中的账号 id → 开始时间:防止超时后的"僵尸同步"与新一轮同步并发写库 */
|
/** 正在同步中的账号 id → 开始时间:防止超时后的"僵尸同步"与新一轮同步并发写库 */
|
||||||
private static activeSyncs: Map<string, number> = new Map();
|
private static activeSyncs: Map<string, number> = new Map();
|
||||||
/** 互斥锁最长持有时间:超过视为异常残留(如进程挂起后网络停摆),允许抢占 */
|
/** 互斥锁最长持有时间:超过视为异常残留(如进程挂起后网络停摆),允许抢占 */
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 给异步操作加超时保护,防止网络挂起导致界面一直转圈
|
* 给异步操作加超时保护,防止网络挂起导致界面一直转圈
|
||||||
*/
|
*/
|
||||||
@@ -38,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}),强制释放并重新同步`);
|
||||||
}
|
}
|
||||||
@@ -71,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}`);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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';
|
||||||
@@ -91,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}` });
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ struct EventEditPage {
|
|||||||
@State scope: string = 'all';
|
@State scope: string = 'all';
|
||||||
@State scopeConfirmShow: boolean = false; // 保存重复日程时的范围选择弹层
|
@State scopeConfirmShow: boolean = false; // 保存重复日程时的范围选择弹层
|
||||||
private pendingDelete: boolean = false; // 该弹层是"保存"还是"删除"流程
|
private pendingDelete: boolean = false; // 该弹层是"保存"还是"删除"流程
|
||||||
|
/**
|
||||||
|
* ⭐ 载入时的**原始行快照**(不可变)。
|
||||||
|
* `doSave` 会就地改写 `this.event` 的 calKey/href/startTime 等字段,
|
||||||
|
* 而"重复系列"的范围计算(EXDATE / UNTIL / 拆系列)必须依据**原始**的
|
||||||
|
* 系列标识(原 calKey、原 href、原发生时刻),否则会算错。
|
||||||
|
*/
|
||||||
|
private original: LocalEvent | null = null;
|
||||||
/** 本次编辑的"目标发生时刻"(未改期时 = 原发生时刻;用户改了开始时间 → 新时刻) */
|
/** 本次编辑的"目标发生时刻"(未改期时 = 原发生时刻;用户改了开始时间 → 新时刻) */
|
||||||
private occStartMs: number = 0;
|
private occStartMs: number = 0;
|
||||||
private event: LocalEvent | null = null;
|
private event: LocalEvent | null = null;
|
||||||
@@ -91,6 +98,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;
|
||||||
@@ -99,6 +122,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) {
|
||||||
@@ -125,7 +164,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 {
|
||||||
@@ -250,24 +304,27 @@ struct EventEditPage {
|
|||||||
* 非重复的普通日程返回 false,直接按原逻辑保存/删除。
|
* 非重复的普通日程返回 false,直接按原逻辑保存/删除。
|
||||||
*/
|
*/
|
||||||
private isSeriesOccurrence(): boolean {
|
private isSeriesOccurrence(): boolean {
|
||||||
if (this.event === null) {
|
const src: LocalEvent | null = this.original ?? this.event;
|
||||||
|
if (src === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return this.event.recurring || this.event.rrule !== '';
|
return src.recurring || src.rrule !== '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取本系列的主事件行(带 RRULE 的那行;找不到则退回当前行) */
|
/** 取本系列的主事件行(带 RRULE 的那行;找不到则退回当前行)。
|
||||||
|
* ⚠️ 必须按**原始** calKey/uid 查找(doSave 会改写 this.event 的 calKey)。 */
|
||||||
private async loadMaster(context: common.Context): Promise<LocalEvent | null> {
|
private async loadMaster(context: common.Context): Promise<LocalEvent | null> {
|
||||||
if (this.event === null) {
|
const src: LocalEvent | null = this.original ?? this.event;
|
||||||
|
if (src === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const rows: LocalEvent[] = await EventDb.queryByUid(context, this.event.calKey, this.event.uid);
|
const rows: LocalEvent[] = await EventDb.queryByUid(context, src.calKey, src.uid);
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (r.rrule !== '') {
|
if (r.rrule !== '') {
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this.event;
|
return src;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 该次发生之前一次发生与之后一次发生的边界(用于 EXDATE / UNTIL / 拆分) */
|
/** 该次发生之前一次发生与之后一次发生的边界(用于 EXDATE / UNTIL / 拆分) */
|
||||||
@@ -321,7 +378,10 @@ struct EventEditPage {
|
|||||||
private async applySeriesEdit(context: common.Context, e: LocalEvent, isNew: boolean,
|
private async applySeriesEdit(context: common.Context, e: LocalEvent, isNew: boolean,
|
||||||
scope: string): Promise<void> {
|
scope: string): Promise<void> {
|
||||||
const target: LocalEvent = e;
|
const target: LocalEvent = e;
|
||||||
if (isNew || !this.isSeriesOccurrence() || this.event === null) {
|
// ⚠️ 用**原始快照**判据: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) {
|
if (isNew) {
|
||||||
await EventDb.insertLocal(context, target);
|
await EventDb.insertLocal(context, target);
|
||||||
} else {
|
} else {
|
||||||
@@ -329,7 +389,6 @@ struct EventEditPage {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cur: LocalEvent = this.event;
|
|
||||||
const bounds: SeriesBounds = await this.seriesBoundaries(context);
|
const bounds: SeriesBounds = await this.seriesBoundaries(context);
|
||||||
if (scope === 'all') {
|
if (scope === 'all') {
|
||||||
// 全部:直接更新当前行。若当前行是某次覆盖实例 → 保留其 recurrenceId 改内容;
|
// 全部:直接更新当前行。若当前行是某次覆盖实例 → 保留其 recurrenceId 改内容;
|
||||||
@@ -339,7 +398,8 @@ struct EventEditPage {
|
|||||||
}
|
}
|
||||||
if (scope === 'one') {
|
if (scope === 'one') {
|
||||||
if (cur.rrule === '' && cur.recurrenceId > 0) {
|
if (cur.rrule === '' && cur.recurrenceId > 0) {
|
||||||
// 已经是覆盖实例 → 直接改它
|
// 已经是覆盖实例 → 直接改它(保留 recurrenceId)
|
||||||
|
target.recurrenceId = cur.recurrenceId;
|
||||||
await EventDb.updateLocal(context, target);
|
await EventDb.updateLocal(context, target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -354,6 +414,11 @@ struct EventEditPage {
|
|||||||
exList.push(occIcs);
|
exList.push(occIcs);
|
||||||
}
|
}
|
||||||
await EventDb.setExdate(context, master.id, exList.join(';'));
|
await EventDb.setExdate(context, master.id, exList.join(';'));
|
||||||
|
// ⚠️ 主事件行本身的内容保持不变("仅这一次"只影响这一次);
|
||||||
|
// 如果用户是在主事件行上改的,需要把主事件改回原值(target 已被写成新时间)
|
||||||
|
if (cur.rrule !== '') {
|
||||||
|
await EventDb.restoreRowById(context, master.id, cur);
|
||||||
|
}
|
||||||
// 覆盖行:uid 同主事件、DTSTART=改到的新时间、RECURRENCE-ID=原始发生时刻
|
// 覆盖行:uid 同主事件、DTSTART=改到的新时间、RECURRENCE-ID=原始发生时刻
|
||||||
const ov = new LocalEvent();
|
const ov = new LocalEvent();
|
||||||
ov.uid = master.uid;
|
ov.uid = master.uid;
|
||||||
@@ -414,11 +479,32 @@ struct EventEditPage {
|
|||||||
} else {
|
} else {
|
||||||
const truncated: string = IcsUtil.rruleWithUntil(master.rrule, bounds.beforeThis);
|
const truncated: string = IcsUtil.rruleWithUntil(master.rrule, bounds.beforeThis);
|
||||||
await EventDb.setRruleForUid(context, master.calKey, master.uid, truncated);
|
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> {
|
private async save(): Promise<void> {
|
||||||
if (this.isSaving || !this.validate()) {
|
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.scopeConfirmShow = true;
|
||||||
|
LogUtil.write('保存判定:重复系列 → 弹出范围选择');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.validate()) {
|
||||||
|
LogUtil.write(`保存判定:校验未通过(${this.statusMsg})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 日程必须落在某个 CalDAV 日历本上("本机"不再是可选目标)——没选到就直接拦下
|
// 日程必须落在某个 CalDAV 日历本上("本机"不再是可选目标)——没选到就直接拦下
|
||||||
@@ -427,12 +513,7 @@ struct EventEditPage {
|
|||||||
this.statusMsg = this.books.length === 0
|
this.statusMsg = this.books.length === 0
|
||||||
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
|
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
|
||||||
: '请选择要同步到的 CalDAV 日历本';
|
: '请选择要同步到的 CalDAV 日历本';
|
||||||
return;
|
LogUtil.write(`保存判定:日历本解析失败(选本=${this.books.length} chosenKey=${this.chosenKey})`);
|
||||||
}
|
|
||||||
// ⭐ 重复日程:先让用户选择"改这一次 / 此次及以后 / 全部"
|
|
||||||
if (this.isSeriesOccurrence()) {
|
|
||||||
this.pendingDelete = false;
|
|
||||||
this.scopeConfirmShow = true;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.doSave('all');
|
await this.doSave('all');
|
||||||
@@ -443,8 +524,16 @@ struct EventEditPage {
|
|||||||
if (this.isSaving) {
|
if (this.isSaving) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// ⭐ 范围确认后回到这里再校验一次:范围弹层打开时并未跳过校验,
|
||||||
|
// 只是把它推迟到"选完范围"之后(失败时弹层已关闭,错误显示在表单状态行)。
|
||||||
|
if (!this.validate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const book = this.chosenBook();
|
const book = this.chosenBook();
|
||||||
if (book === null) {
|
if (book === null) {
|
||||||
|
this.statusMsg = this.books.length === 0
|
||||||
|
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
|
||||||
|
: '请选择要同步到的 CalDAV 日历本';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.isSaving = true;
|
this.isSaving = true;
|
||||||
@@ -589,7 +678,7 @@ struct EventEditPage {
|
|||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cur: LocalEvent = this.event;
|
const cur: LocalEvent = this.original ?? this.event;
|
||||||
const isSeries: boolean = this.isSeriesOccurrence();
|
const isSeries: boolean = this.isSeriesOccurrence();
|
||||||
if (!isSeries || scope === 'all') {
|
if (!isSeries || scope === 'all') {
|
||||||
// 普通日程 / 删除全部:整行标记删除(系列删除由 pushSeries 处理成 DELETE 资源)
|
// 普通日程 / 删除全部:整行标记删除(系列删除由 pushSeries 处理成 DELETE 资源)
|
||||||
@@ -1171,6 +1260,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 {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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';
|
||||||
@@ -584,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}` });
|
||||||
@@ -633,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);
|
||||||
@@ -654,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;
|
||||||
}
|
}
|
||||||
@@ -684,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} 个账号)` });
|
||||||
|
|||||||
Reference in New Issue
Block a user