修复了部分日程没有提醒时间的问题。
This commit is contained in:
@@ -126,9 +126,11 @@ export class AccountStore {
|
|||||||
const store: preferences.Preferences =
|
const store: preferences.Preferences =
|
||||||
await preferences.getPreferences(context, AccountStore.STORE);
|
await preferences.getPreferences(context, AccountStore.STORE);
|
||||||
const count: number = await store.get(AccountStore.COUNT_KEY, 0) as number;
|
const count: number = await store.get(AccountStore.COUNT_KEY, 0) as number;
|
||||||
|
console.info(`[AccountStore] loadAll: count=${count}`);
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const raw = await store.get(`acc_${i}`, '') as string;
|
const raw = await store.get(`acc_${i}`, '') as string;
|
||||||
if (raw === '') {
|
if (raw === '') {
|
||||||
|
console.warn(`[AccountStore] loadAll: acc_${i} 为空`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const acc = AccountStore.decodeAccount(raw);
|
const acc = AccountStore.decodeAccount(raw);
|
||||||
@@ -139,6 +141,8 @@ export class AccountStore {
|
|||||||
migrated = true;
|
migrated = true;
|
||||||
}
|
}
|
||||||
result.push(acc);
|
result.push(acc);
|
||||||
|
} else {
|
||||||
|
console.error(`[AccountStore] loadAll: acc_${i} 解码失败,raw 长度=${raw.length}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -157,10 +161,19 @@ export class AccountStore {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async saveAll(context: common.Context, accounts: DavAccount[]): Promise<void> {
|
/**
|
||||||
|
* 保存全部账号。
|
||||||
|
* 防御:存储里已有账号时,禁止用空列表覆盖(调用方若因读取异常拿到空列表再回写,
|
||||||
|
* 会把所有账号抹掉)。仅删除账号的合法场景通过 force=true 放行。
|
||||||
|
*/
|
||||||
|
static async saveAll(context: common.Context, accounts: DavAccount[], force: boolean = false): Promise<void> {
|
||||||
const store: preferences.Preferences =
|
const store: preferences.Preferences =
|
||||||
await preferences.getPreferences(context, AccountStore.STORE);
|
await preferences.getPreferences(context, AccountStore.STORE);
|
||||||
const oldCount: number = await store.get(AccountStore.COUNT_KEY, 0) as number;
|
const oldCount: number = await store.get(AccountStore.COUNT_KEY, 0) as number;
|
||||||
|
if (accounts.length === 0 && oldCount > 0 && !force) {
|
||||||
|
console.error(`[AccountStore] 拒绝用空列表覆盖账号存储(原有 ${oldCount} 个账号)`);
|
||||||
|
throw new Error('账号列表为空,已阻止覆盖存储(保护原有账号数据)');
|
||||||
|
}
|
||||||
for (let i = 0; i < oldCount; i++) {
|
for (let i = 0; i < oldCount; i++) {
|
||||||
store.delete(`acc_${i}`);
|
store.delete(`acc_${i}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { preferences } from '@kit.ArkData';
|
import { preferences } from '@kit.ArkData';
|
||||||
import { common } from '@kit.AbilityKit';
|
import { common } from '@kit.AbilityKit';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { LogUtil } from './LogUtil';
|
||||||
|
|
||||||
export class AppSettings {
|
export class AppSettings {
|
||||||
private static readonly STORE: string = 'sync_settings';
|
private static readonly STORE: string = 'sync_settings';
|
||||||
@@ -12,6 +13,50 @@ export class AppSettings {
|
|||||||
private static readonly KEY_SYS_MODE: string = 'sys_cal_mode'; // 'display' | 'backup'
|
private static readonly KEY_SYS_MODE: string = 'sys_cal_mode'; // 'display' | 'backup'
|
||||||
private static readonly KEY_BACKUP_KEY: string = 'sys_backup_cal_key'; // 备份目标 DAV 日历本
|
private static readonly KEY_BACKUP_KEY: string = 'sys_backup_cal_key'; // 备份目标 DAV 日历本
|
||||||
private static readonly KEY_MANUAL_READONLY: string = 'manual_readonly_keys'; // 手动标记只读的 calKey
|
private static readonly KEY_MANUAL_READONLY: string = 'manual_readonly_keys'; // 手动标记只读的 calKey
|
||||||
|
private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。
|
||||||
|
* 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。
|
||||||
|
*/
|
||||||
|
static async isFullRefetchPending(context: common.Context): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const store: preferences.Preferences =
|
||||||
|
await preferences.getPreferences(context, AppSettings.STORE);
|
||||||
|
return !(await store.get(AppSettings.KEY_FULL_REFETCH, false) as boolean);
|
||||||
|
} catch (err) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async markFullRefetchDone(context: common.Context): Promise<void> {
|
||||||
|
try {
|
||||||
|
const store: preferences.Preferences =
|
||||||
|
await preferences.getPreferences(context, AppSettings.STORE);
|
||||||
|
await store.put(AppSettings.KEY_FULL_REFETCH, true);
|
||||||
|
await store.flush();
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as BusinessError;
|
||||||
|
console.error(`标记全量重拉完成失败: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动重置全量重拉标记:下次同步忽略 etag 复用,所有资源 GET 完整 ICS 重建本地数据。
|
||||||
|
* 用于修复历史落库的残缺字段(如 reminder=0 但 etag 从未变化,增量同步永远无法纠正)。
|
||||||
|
*/
|
||||||
|
static async resetFullRefetch(context: common.Context): Promise<void> {
|
||||||
|
try {
|
||||||
|
const store: preferences.Preferences =
|
||||||
|
await preferences.getPreferences(context, AppSettings.STORE);
|
||||||
|
await store.put(AppSettings.KEY_FULL_REFETCH, false);
|
||||||
|
await store.flush();
|
||||||
|
LogUtil.write('已重置全量重拉标记:下次同步将 GET 全部资源重建本地数据');
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as BusinessError;
|
||||||
|
console.error(`重置全量重拉标记失败: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手动标记为只读的日历本 calKey 列表。
|
* 手动标记为只读的日历本 calKey 列表。
|
||||||
|
|||||||
@@ -110,6 +110,28 @@ export class DavClient {
|
|||||||
return 'Basic ' + buffer.from(`${username}:${password}`).toString('base64');
|
return 'Basic ' + buffer.from(`${username}:${password}`).toString('base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET 拉取单个资源的原始 ICS(调试诊断用) */
|
||||||
|
static async getRaw(url: string, auth: string): Promise<string> {
|
||||||
|
const httpRequest = http.createHttp();
|
||||||
|
try {
|
||||||
|
const resp: http.HttpResponse = await httpRequest.request(url, {
|
||||||
|
method: http.RequestMethod.GET,
|
||||||
|
header: {
|
||||||
|
'Authorization': auth,
|
||||||
|
'User-Agent': 'SyncCalendar/1.0'
|
||||||
|
},
|
||||||
|
connectTimeout: 10000,
|
||||||
|
readTimeout: 15000
|
||||||
|
});
|
||||||
|
if (resp.responseCode >= 200 && resp.responseCode < 300) {
|
||||||
|
return resp.result as string;
|
||||||
|
}
|
||||||
|
return `HTTP ${resp.responseCode}`;
|
||||||
|
} finally {
|
||||||
|
httpRequest.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 写权限探测:向日历本 PUT 一个探测资源——
|
* 写权限探测:向日历本 PUT 一个探测资源——
|
||||||
* 2xx → 可写(随后删除探测资源);403/401 等 → 只读;网络异常 → 乐观按可写。
|
* 2xx → 可写(随后删除探测资源);403/401 等 → 只读;网络异常 → 乐观按可写。
|
||||||
@@ -170,6 +192,58 @@ export class DavClient {
|
|||||||
return DavClient.reportComponents(href, auth, 'VEVENT', '');
|
return DavClient.reportComponents(href, auth, 'VEVENT', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REPORT calendar-query:仅拉取 href + getetag(不含 calendar-data)。
|
||||||
|
* 部分服务器(如群晖)的 REPORT calendar-data 会剥离 VALARM 导致提醒丢失,
|
||||||
|
* 因此数据改为对变更资源逐个 GET 补拉(GET 返回完整 ICS)。
|
||||||
|
*/
|
||||||
|
static async reportEtags(href: string, auth: string): Promise<RemoteItem[]> {
|
||||||
|
const body: string = '<?xml version="1.0" encoding="utf-8"?>' +
|
||||||
|
'<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">' +
|
||||||
|
'<d:prop><d:getetag/></d:prop>' +
|
||||||
|
'<c:filter><c:comp-filter name="VCALENDAR">' +
|
||||||
|
'<c:comp-filter name="VEVENT">' +
|
||||||
|
'</c:comp-filter></c:comp-filter></c:filter></c:calendar-query>';
|
||||||
|
const httpRequest = http.createHttp();
|
||||||
|
try {
|
||||||
|
const resp: http.HttpResponse = await httpRequest.request(href, {
|
||||||
|
method: 'REPORT' as http.RequestMethod,
|
||||||
|
header: {
|
||||||
|
'Authorization': auth,
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
|
'Depth': '1',
|
||||||
|
'User-Agent': 'SyncCalendar/1.0'
|
||||||
|
},
|
||||||
|
extraData: body,
|
||||||
|
connectTimeout: 10000,
|
||||||
|
readTimeout: 30000
|
||||||
|
});
|
||||||
|
LogUtil.write(`HTTP REPORT(etag) ${href} → ${resp.responseCode}`);
|
||||||
|
if (resp.responseCode < 200 || resp.responseCode >= 300) {
|
||||||
|
throw new Error(`服务器返回状态码 ${resp.responseCode}`);
|
||||||
|
}
|
||||||
|
const xml: string = resp.result as string;
|
||||||
|
const items: RemoteItem[] = [];
|
||||||
|
const blocks: string[] = xml.split(/<\/[\w-]*:?response>/i);
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (!/<[\w-]*:?response[\s>]/i.test(block)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const resHref: string = DavClient.extractTag(block, 'href');
|
||||||
|
if (resHref === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const item = new RemoteItem();
|
||||||
|
item.href = resHref;
|
||||||
|
item.etag = DavClient.extractTag(block, 'getetag').replace(/"/g, '');
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
} finally {
|
||||||
|
httpRequest.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** REPORT calendar-query:拉取某日历本内所有 VTODO 待办(不限时间范围,量小) */
|
/** REPORT calendar-query:拉取某日历本内所有 VTODO 待办(不限时间范围,量小) */
|
||||||
static async reportTodos(href: string, auth: string): Promise<RemoteItem[]> {
|
static async reportTodos(href: string, auth: string): Promise<RemoteItem[]> {
|
||||||
return DavClient.reportComponents(href, auth, 'VTODO', '');
|
return DavClient.reportComponents(href, auth, 'VTODO', '');
|
||||||
|
|||||||
@@ -409,6 +409,23 @@ export class EventDb {
|
|||||||
return `新增${added} 更新${updated} 删除${removed} 不变${unchanged}`;
|
return `新增${added} 更新${updated} 删除${removed} 不变${unchanged}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 查询某日历本下同类型的全部行(同步 etag 比对用,含 dirty 行) */
|
||||||
|
static async queryByCalKey(context: common.Context, calKey: string, kind: string): Promise<LocalEvent[]> {
|
||||||
|
const store = await EventDb.getDb(context);
|
||||||
|
const predicates = new relationalStore.RdbPredicates('events');
|
||||||
|
predicates.equalTo('cal_key', calKey).and().equalTo('kind', kind);
|
||||||
|
const rs = await store.query(predicates);
|
||||||
|
const list: LocalEvent[] = [];
|
||||||
|
try {
|
||||||
|
while (rs.goToNextRow()) {
|
||||||
|
list.push(EventDb.fromRow(rs));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rs.close();
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
/** 按 UID 判断事件是否已存在(系统日历备份导入的幂等判重) */
|
/** 按 UID 判断事件是否已存在(系统日历备份导入的幂等判重) */
|
||||||
static async uidExists(context: common.Context, uid: string): Promise<boolean> {
|
static async uidExists(context: common.Context, uid: string): Promise<boolean> {
|
||||||
const store = await EventDb.getDb(context);
|
const store = await EventDb.getDb(context);
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// entry/src/main/ets/common/ScreenKeeper.ets
|
||||||
|
// 同步期间保持屏幕常亮:鸿蒙熄屏后应用进程会被挂起,网络请求停摆导致同步超时。
|
||||||
|
// 引用计数支持多处同步(首页/账号页/后台入口)叠加,最后一个释放时才恢复熄屏。
|
||||||
|
import { window } from '@kit.ArkUI';
|
||||||
|
import { common } from '@kit.AbilityKit';
|
||||||
|
|
||||||
|
export class ScreenKeeper {
|
||||||
|
private static count: number = 0;
|
||||||
|
|
||||||
|
/** 同步开始时调用:保持屏幕常亮(失败静默,不影响同步) */
|
||||||
|
static async acquire(context: common.UIAbilityContext): Promise<void> {
|
||||||
|
ScreenKeeper.count++;
|
||||||
|
try {
|
||||||
|
const win = await window.getLastWindow(context);
|
||||||
|
await win.setWindowKeepScreenOn(true);
|
||||||
|
} catch (err) {
|
||||||
|
// 获取窗口失败(如页面已销毁)不影响同步
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步结束时调用:恢复系统默认熄屏策略 */
|
||||||
|
static async release(context: common.UIAbilityContext): Promise<void> {
|
||||||
|
ScreenKeeper.count = Math.max(0, ScreenKeeper.count - 1);
|
||||||
|
if (ScreenKeeper.count > 0) {
|
||||||
|
return; // 还有其他同步在进行,保持常亮
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const win = await window.getLastWindow(context);
|
||||||
|
await win.setWindowKeepScreenOn(false);
|
||||||
|
} catch (err) {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,16 @@ import { BusinessError } from '@kit.BasicServicesKit';
|
|||||||
import { DavAccount } from './AccountStore';
|
import { DavAccount } 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 } from './DavClient';
|
import { DavClient, DavColorEntry, RemoteItem } from './DavClient';
|
||||||
import { LogUtil } from './LogUtil';
|
import { LogUtil } from './LogUtil';
|
||||||
|
import { AppSettings } from './AppSettings';
|
||||||
|
|
||||||
export class SyncEngine {
|
export class SyncEngine {
|
||||||
|
/** 正在同步中的账号 id → 开始时间:防止超时后的"僵尸同步"与新一轮同步并发写库 */
|
||||||
|
private static activeSyncs: Map<string, number> = new Map();
|
||||||
|
/** 互斥锁最长持有时间:超过视为异常残留(如进程挂起后网络停摆),允许抢占 */
|
||||||
|
private static readonly LOCK_STALE_MS: number = 15 * 60 * 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 给异步操作加超时保护,防止网络挂起导致界面一直转圈
|
* 给异步操作加超时保护,防止网络挂起导致界面一直转圈
|
||||||
*/
|
*/
|
||||||
@@ -26,11 +32,28 @@ export class SyncEngine {
|
|||||||
* 返回远端事件总数(拉取侧)。带 120 秒超时保护。
|
* 返回远端事件总数(拉取侧)。带 120 秒超时保护。
|
||||||
*/
|
*/
|
||||||
static async syncAccount(context: common.Context, acc: DavAccount): Promise<number> {
|
static async syncAccount(context: common.Context, acc: DavAccount): Promise<number> {
|
||||||
|
// 同一账号互斥:超时被掐断后内层任务仍在后台运行,期间不允许再次同步该账号。
|
||||||
|
// 锁超过 LOCK_STALE_MS 未释放(如熄屏挂起导致任务冻结)则视为残留,允许抢占
|
||||||
|
const heldSince: number | undefined = SyncEngine.activeSyncs.get(acc.id);
|
||||||
|
if (heldSince !== undefined) {
|
||||||
|
if (Date.now() - heldSince < SyncEngine.LOCK_STALE_MS) {
|
||||||
|
throw new Error('该账号正在同步中,请稍后再试');
|
||||||
|
}
|
||||||
|
LogUtil.write(`同步互斥锁超时残留(${acc.name}),强制释放并重新同步`);
|
||||||
|
}
|
||||||
|
SyncEngine.activeSyncs.set(acc.id, Date.now());
|
||||||
const t0: number = Date.now();
|
const t0: number = Date.now();
|
||||||
LogUtil.write(`========== 同步账号「${acc.name}」开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`);
|
LogUtil.write(`========== 同步账号「${acc.name}」开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`);
|
||||||
try {
|
try {
|
||||||
const r: number = await SyncEngine.withTimeout<number>(
|
// 全量重拉(升级后首次)要逐个 GET 所有资源,放宽超时到 10 分钟;常规 5 分钟
|
||||||
SyncEngine.syncAccountInner(context, acc), 120000);
|
const fullRefetch: boolean = await AppSettings.isFullRefetchPending(context);
|
||||||
|
const timeoutMs: number = fullRefetch ? 600000 : 300000;
|
||||||
|
// 内层任务真正结束(无论成败)才释放互斥锁;超时后它仍会在后台跑完
|
||||||
|
const inner: Promise<number> = SyncEngine.syncAccountInner(context, acc);
|
||||||
|
inner.catch((): void => {}).finally((): void => {
|
||||||
|
SyncEngine.activeSyncs.delete(acc.id);
|
||||||
|
});
|
||||||
|
const r: number = await SyncEngine.withTimeout<number>(inner, timeoutMs);
|
||||||
LogUtil.write(`同步账号「${acc.name}」完成:拉取 ${r} 条日程,耗时 ${Math.round((Date.now() - t0) / 1000)} 秒`);
|
LogUtil.write(`同步账号「${acc.name}」完成:拉取 ${r} 条日程,耗时 ${Math.round((Date.now() - t0) / 1000)} 秒`);
|
||||||
return r;
|
return r;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -46,7 +69,14 @@ export class SyncEngine {
|
|||||||
await SyncEngine.refreshCalendarColors(acc, auth);
|
await SyncEngine.refreshCalendarColors(acc, auth);
|
||||||
// 1) 推送该账号日历本下的本地修改
|
// 1) 推送该账号日历本下的本地修改
|
||||||
await SyncEngine.pushDirtyForAccount(context, acc, auth);
|
await SyncEngine.pushDirtyForAccount(context, acc, auth);
|
||||||
// 2) 拉取远端变更(全量 REPORT,etag 增量落库)
|
// 2) 拉取远端变更:REPORT 仅取 etag;新增/变化的资源用 GET 补拉完整 ICS。
|
||||||
|
// 原因:群晖等服务器的 REPORT calendar-data 会剥离 VALARM(提醒丢失),
|
||||||
|
// 而 GET 返回完整 ICS;etag 未变的资源直接复用本地完整数据,几乎零开销。
|
||||||
|
// 首次升级后执行一次全量重拉,修复旧模式落库的残缺数据(如 reminder=0)。
|
||||||
|
const fullRefetch: boolean = await AppSettings.isFullRefetchPending(context);
|
||||||
|
if (fullRefetch) {
|
||||||
|
LogUtil.write('一次性全量重拉:忽略 etag 复用,全部资源 GET 完整 ICS');
|
||||||
|
}
|
||||||
let changed: number = 0;
|
let changed: number = 0;
|
||||||
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];
|
||||||
@@ -54,36 +84,82 @@ export class SyncEngine {
|
|||||||
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 = await DavClient.reportCalendar(href, auth);
|
const items: RemoteItem[] = await DavClient.reportEtags(href, auth);
|
||||||
LogUtil.write(`日历本[${i}]「${calName}」REPORT 返回 ${items.length} 个资源`);
|
LogUtil.write(`日历本[${i}]「${calName}」REPORT 返回 ${items.length} 个资源`);
|
||||||
|
// 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag)
|
||||||
|
const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
|
||||||
|
const byUid: Map<string, LocalEvent[]> = new Map();
|
||||||
|
for (const row of existingRows) {
|
||||||
|
const arr: LocalEvent[] | undefined = byUid.get(row.uid);
|
||||||
|
if (arr === undefined) {
|
||||||
|
byUid.set(row.uid, [row]);
|
||||||
|
} else {
|
||||||
|
arr.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const originMatch = /https?:\/\/[^/]+/i.exec(href);
|
||||||
|
const origin: string = originMatch !== null ? originMatch[0] : '';
|
||||||
const remote: RemoteEvent[] = [];
|
const remote: RemoteEvent[] = [];
|
||||||
|
let fetched: number = 0;
|
||||||
let parseFail: number = 0;
|
let parseFail: number = 0;
|
||||||
let failSample: string = '';
|
const needFetch: RemoteItem[] = [];
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
const parsed: RemoteEvent[] = IcsUtil.parse(it.ics);
|
const resUid: string = SyncEngine.resUidFromHref(it.href);
|
||||||
if (parsed.length === 0) {
|
const group: LocalEvent[] | undefined = byUid.get(resUid);
|
||||||
parseFail++;
|
if (!fullRefetch && group !== undefined && group.length > 0 && !group[0].dirty
|
||||||
if (failSample === '') {
|
&& group[0].etag === it.etag) {
|
||||||
failSample = it.ics.replace(/\r?\n/g, ' ⏎ ').substring(0, 600);
|
// etag 未变:直接用本地行还原远端数据(含 reminder 等完整字段)
|
||||||
|
for (const row of group) {
|
||||||
|
remote.push(SyncEngine.rowToRemote(row, it.etag));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 一个资源可能包含主事件 + 单次覆盖实例(RECURRENCE-ID),全部入库
|
needFetch.push(it);
|
||||||
|
}
|
||||||
|
// 并发 GET 补拉(每批 5 个),避免全量重拉时串行请求超时
|
||||||
|
const batchSize: number = 5;
|
||||||
|
for (let b: number = 0; b < needFetch.length; b += batchSize) {
|
||||||
|
const batch: RemoteItem[] = needFetch.slice(b, b + batchSize);
|
||||||
|
const results: RemoteEvent[][] = await Promise.all(batch.map(async (it: RemoteItem): Promise<RemoteEvent[]> => {
|
||||||
|
const url: string = it.href.startsWith('http') ? it.href : origin + it.href;
|
||||||
|
try {
|
||||||
|
const raw: string = await DavClient.getRaw(url, auth);
|
||||||
|
if (!raw.startsWith('BEGIN:VCALENDAR')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const resUid: string = SyncEngine.resUidFromHref(it.href);
|
||||||
|
const parsed: RemoteEvent[] = IcsUtil.parse(raw);
|
||||||
|
const out: RemoteEvent[] = [];
|
||||||
for (const r of parsed) {
|
for (const r of parsed) {
|
||||||
if (r.uid === 'syncprobe') {
|
if (r.uid === 'syncprobe') {
|
||||||
continue; // 写权限探测资源(万一删除失败),不入库展示
|
continue; // 写权限探测资源(万一删除失败),不入库展示
|
||||||
}
|
}
|
||||||
if (r.uid === '') {
|
if (r.uid === '') {
|
||||||
r.uid = SyncEngine.uidFromHref(it.href);
|
r.uid = resUid;
|
||||||
}
|
}
|
||||||
r.etag = it.etag;
|
r.etag = it.etag;
|
||||||
|
out.push(r);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch (err) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
for (const arr of results) {
|
||||||
|
if (arr.length === 0) {
|
||||||
|
parseFail++;
|
||||||
|
} else {
|
||||||
|
fetched++;
|
||||||
|
for (const r of arr) {
|
||||||
remote.push(r);
|
remote.push(r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (parseFail > 0) {
|
|
||||||
LogUtil.write(`日历本[${i}]「${calName}」有 ${parseFail} 个资源解析出 0 条日程,首个样本: ${failSample}`);
|
|
||||||
}
|
}
|
||||||
LogUtil.write(`日历本[${i}]「${calName}」解析出 ${remote.length} 条日程,开始落库`);
|
}
|
||||||
|
if (parseFail > 0) {
|
||||||
|
LogUtil.write(`日历本[${i}]「${calName}」有 ${parseFail} 个资源拉取/解析失败`);
|
||||||
|
}
|
||||||
|
LogUtil.write(`日历本[${i}]「${calName}」补拉变更资源 ${fetched} 个,共 ${remote.length} 条日程,开始落库`);
|
||||||
const stat: string = await EventDb.applyRemote(context, calKey, href, remote, false);
|
const stat: string = await EventDb.applyRemote(context, calKey, href, remote, false);
|
||||||
LogUtil.write(`日历本[${i}]「${calName}」日程落库完成:${stat},耗时 ${Math.round((Date.now() - t1) / 1000)} 秒`);
|
LogUtil.write(`日历本[${i}]「${calName}」日程落库完成:${stat},耗时 ${Math.round((Date.now() - t1) / 1000)} 秒`);
|
||||||
changed += remote.length;
|
changed += remote.length;
|
||||||
@@ -167,6 +243,43 @@ export class SyncEngine {
|
|||||||
return last.endsWith('.ics') ? last.substring(0, last.length - 4) : last;
|
return last.endsWith('.ics') ? last.substring(0, last.length - 4) : last;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从资源 URL 提取 UID(URL 解码后,用于与 DB 中 parse 出的真实 uid 匹配) */
|
||||||
|
private static resUidFromHref(href: string): string {
|
||||||
|
const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
|
||||||
|
if (segs.length === 0) {
|
||||||
|
return String(Date.now());
|
||||||
|
}
|
||||||
|
let last: string = segs[segs.length - 1];
|
||||||
|
if (last.endsWith('.ics')) {
|
||||||
|
last = last.substring(0, last.length - 4);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
last = decodeURIComponent(last);
|
||||||
|
} catch (err) {
|
||||||
|
// 解码失败保持原样
|
||||||
|
}
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本地行 → 远端事件(etag 未变的资源复用本地完整数据,含 reminder) */
|
||||||
|
private static rowToRemote(e: LocalEvent, etag: string): RemoteEvent {
|
||||||
|
const r = new RemoteEvent();
|
||||||
|
r.uid = e.uid;
|
||||||
|
r.etag = etag;
|
||||||
|
r.title = e.title;
|
||||||
|
r.description = e.description;
|
||||||
|
r.location = e.location;
|
||||||
|
r.startTime = e.startTime;
|
||||||
|
r.endTime = e.endTime;
|
||||||
|
r.isAllDay = e.isAllDay;
|
||||||
|
r.recurring = e.recurring;
|
||||||
|
r.completed = e.completed;
|
||||||
|
r.rrule = e.rrule;
|
||||||
|
r.exdate = e.exdate;
|
||||||
|
r.reminder = e.reminder;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 推送指定账号日历本下的待同步事件(新建/修改 → PUT;删除 → DELETE)
|
* 推送指定账号日历本下的待同步事件(新建/修改 → PUT;删除 → DELETE)
|
||||||
*/
|
*/
|
||||||
@@ -237,6 +350,11 @@ export class SyncEngine {
|
|||||||
*/
|
*/
|
||||||
static async pruneOrphanRows(context: common.Context, accounts: DavAccount[]): Promise<void> {
|
static async pruneOrphanRows(context: common.Context, accounts: DavAccount[]): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
// 防御:账号列表为空时(如同步中途读取异常),绝不能把所有 CalDAV 日程当孤儿清理
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
LogUtil.write('清理孤儿日程行跳过:账号列表为空(防御保护)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const accIds: string[] = accounts.map((a: DavAccount): string => a.id);
|
const accIds: string[] = accounts.map((a: DavAccount): string => a.id);
|
||||||
const removed: number = await EventDb.pruneOrphanCalKeys(context, accIds);
|
const removed: number = await EventDb.pruneOrphanCalKeys(context, accIds);
|
||||||
if (removed > 0) {
|
if (removed > 0) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { SyncEngine } 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 { ScreenKeeper } from '../common/ScreenKeeper';
|
||||||
|
|
||||||
@Entry
|
@Entry
|
||||||
@Component
|
@Component
|
||||||
@@ -68,14 +69,19 @@ struct AccountsPage {
|
|||||||
}
|
}
|
||||||
this.syncing = true;
|
this.syncing = true;
|
||||||
this.syncingId = acc.id;
|
this.syncingId = acc.id;
|
||||||
|
// 同步期间保持屏幕常亮:熄屏会挂起进程导致请求停摆、同步超时
|
||||||
|
const uiCtx = this.getUIContext().getHostContext();
|
||||||
|
if (uiCtx !== undefined) {
|
||||||
|
await ScreenKeeper.acquire(uiCtx as common.UIAbilityContext);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const context = this.getUIContext().getHostContext();
|
const context = this.getUIContext().getHostContext();
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (acc.type === TYPE_CALDAV) {
|
if (acc.type === TYPE_CALDAV) {
|
||||||
await SyncEngine.withTimeout(
|
// 超时保护由 syncAccount 内部处理(全量重拉 10 分钟 / 常规 2 分钟),不再套外层超时
|
||||||
SyncEngine.syncAccount(context as common.UIAbilityContext, acc), 120000);
|
await SyncEngine.syncAccount(context as common.UIAbilityContext, acc);
|
||||||
await SyncEngine.pruneOrphanRows(context, this.accounts);
|
await SyncEngine.pruneOrphanRows(context, this.accounts);
|
||||||
}
|
}
|
||||||
acc.itemCount = acc.calendarHrefs.length;
|
acc.itemCount = acc.calendarHrefs.length;
|
||||||
@@ -90,6 +96,9 @@ struct AccountsPage {
|
|||||||
} finally {
|
} finally {
|
||||||
this.syncing = false;
|
this.syncing = false;
|
||||||
this.syncingId = '';
|
this.syncingId = '';
|
||||||
|
if (uiCtx !== undefined) {
|
||||||
|
await ScreenKeeper.release(uiCtx as common.UIAbilityContext);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import { LunarUtil } from '../common/LunarUtil';
|
|||||||
import { CardDataService } from '../common/CardDataService';
|
import { CardDataService } from '../common/CardDataService';
|
||||||
import { ReminderService } from '../common/ReminderService';
|
import { ReminderService } from '../common/ReminderService';
|
||||||
import { AppSettings } from '../common/AppSettings';
|
import { AppSettings } from '../common/AppSettings';
|
||||||
import { EventDb, LocalEvent } from '../common/EventDb';
|
import { EventDb, LocalEvent, RemoteEvent } from '../common/EventDb';
|
||||||
import { RruleUtil } from '../common/RruleUtil';
|
import { RruleUtil } from '../common/RruleUtil';
|
||||||
import { IcsUtil } from '../common/IcsUtil';
|
import { IcsUtil } from '../common/IcsUtil';
|
||||||
|
import { DavClient, RemoteItem } from '../common/DavClient';
|
||||||
import { SystemCalendarImport } from '../common/SystemCalendarImport';
|
import { SystemCalendarImport } from '../common/SystemCalendarImport';
|
||||||
|
import { ScreenKeeper } from '../common/ScreenKeeper';
|
||||||
|
|
||||||
/** 月视图单元格 */
|
/** 月视图单元格 */
|
||||||
class MonthCell {
|
class MonthCell {
|
||||||
@@ -92,11 +94,9 @@ struct Index {
|
|||||||
|
|
||||||
/** 从设置页/账号页返回时刷新(同步间隔、系统日历开关立即生效),并处理编辑账号后的待同步 */
|
/** 从设置页/账号页返回时刷新(同步间隔、系统日历开关立即生效),并处理编辑账号后的待同步 */
|
||||||
onPageShow(): void {
|
onPageShow(): void {
|
||||||
if (this.lastSyncTime > 0 || this.accounts.length > 0) {
|
// 无条件刷新:之前"列表为空就跳过"的条件会导致账号列表卡死在空状态,
|
||||||
|
// 同步时误报"没有可同步的账号"
|
||||||
this.reloadAll().then((): Promise<void> => this.handlePendingSync());
|
this.reloadAll().then((): Promise<void> => this.handlePendingSync());
|
||||||
} else {
|
|
||||||
this.handlePendingSync();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handlePendingSync(): Promise<void> {
|
private async handlePendingSync(): Promise<void> {
|
||||||
@@ -298,13 +298,17 @@ struct Index {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.syncing = true;
|
this.syncing = true;
|
||||||
|
// 同步期间保持屏幕常亮:熄屏会挂起进程导致请求停摆、同步超时
|
||||||
|
const uiCtx = this.getUIContext().getHostContext();
|
||||||
|
if (uiCtx !== undefined) {
|
||||||
|
await ScreenKeeper.acquire(uiCtx as common.UIAbilityContext);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const context = this.getUIContext().getHostContext();
|
const context = this.getUIContext().getHostContext();
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await SyncEngine.withTimeout(
|
await SyncEngine.syncAccount(context as common.UIAbilityContext, acc);
|
||||||
SyncEngine.syncAccount(context as common.UIAbilityContext, acc), 120000);
|
|
||||||
await SyncEngine.pruneOrphanRows(context, this.accounts);
|
await SyncEngine.pruneOrphanRows(context, this.accounts);
|
||||||
acc.lastSyncTime = this.formatNow();
|
acc.lastSyncTime = this.formatNow();
|
||||||
await AccountStore.saveAll(context, this.accounts);
|
await AccountStore.saveAll(context, this.accounts);
|
||||||
@@ -316,6 +320,9 @@ struct Index {
|
|||||||
.showToast({ message: `同步失败:${e.message}` });
|
.showToast({ message: `同步失败:${e.message}` });
|
||||||
} finally {
|
} finally {
|
||||||
this.syncing = false;
|
this.syncing = false;
|
||||||
|
if (uiCtx !== undefined) {
|
||||||
|
await ScreenKeeper.release(uiCtx as common.UIAbilityContext);
|
||||||
|
}
|
||||||
await this.reloadAll();
|
await this.reloadAll();
|
||||||
// 同步后刷新服务卡片
|
// 同步后刷新服务卡片
|
||||||
try {
|
try {
|
||||||
@@ -339,12 +346,22 @@ struct Index {
|
|||||||
if (this.syncing) {
|
if (this.syncing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 防御:账号列表为空时不执行同步、不清理孤儿行、不回写账号存储,
|
||||||
|
// 防止异常状态下把所有 CalDAV 日程和账号配置抹掉
|
||||||
|
if (this.accounts.length === 0) {
|
||||||
|
if (manual) {
|
||||||
|
this.getUIContext().getPromptAction().showToast({ message: '没有可同步的账号' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.syncing = true;
|
this.syncing = true;
|
||||||
const context = this.getUIContext().getHostContext();
|
const context = this.getUIContext().getHostContext();
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
this.syncing = false;
|
this.syncing = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 同步期间保持屏幕常亮:熄屏会挂起进程导致请求停摆、同步超时
|
||||||
|
await ScreenKeeper.acquire(context as common.UIAbilityContext);
|
||||||
let ok: number = 0;
|
let ok: number = 0;
|
||||||
let failMsg: string = '';
|
let failMsg: string = '';
|
||||||
// 备份模式:先把系统本地日历导入目标日历本(幂等),再随正常同步推送上服务器
|
// 备份模式:先把系统本地日历导入目标日历本(幂等),再随正常同步推送上服务器
|
||||||
@@ -358,8 +375,7 @@ struct Index {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await SyncEngine.withTimeout(
|
await SyncEngine.syncAccount(context as common.UIAbilityContext, acc);
|
||||||
SyncEngine.syncAccount(context as common.UIAbilityContext, acc), 120000);
|
|
||||||
acc.lastSyncTime = this.formatNow();
|
acc.lastSyncTime = this.formatNow();
|
||||||
ok++;
|
ok++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -371,11 +387,16 @@ struct Index {
|
|||||||
await SyncEngine.settleLocalEvents(context);
|
await SyncEngine.settleLocalEvents(context);
|
||||||
await SyncEngine.pruneOrphanRows(context, this.accounts);
|
await SyncEngine.pruneOrphanRows(context, this.accounts);
|
||||||
await AccountStore.saveAll(context, this.accounts);
|
await AccountStore.saveAll(context, this.accounts);
|
||||||
|
// 全部账号同步成功才结束"一次性全量重拉"状态(修复旧模式落库的残缺数据)
|
||||||
|
if (failMsg === '') {
|
||||||
|
await AppSettings.markFullRefetchDone(context);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as BusinessError;
|
const e = err as BusinessError;
|
||||||
failMsg = e.message;
|
failMsg = e.message;
|
||||||
}
|
}
|
||||||
this.syncing = false;
|
this.syncing = false;
|
||||||
|
await ScreenKeeper.release(context as common.UIAbilityContext);
|
||||||
if (failMsg !== '') {
|
if (failMsg !== '') {
|
||||||
this.getUIContext().getPromptAction()
|
this.getUIContext().getPromptAction()
|
||||||
.showToast({ message: ok > 0 ? `同步完成 ${ok} 个,失败:${failMsg}` : `同步失败:${failMsg}` });
|
.showToast({ message: ok > 0 ? `同步完成 ${ok} 个,失败:${failMsg}` : `同步失败:${failMsg}` });
|
||||||
@@ -910,6 +931,7 @@ struct Index {
|
|||||||
};
|
};
|
||||||
const lines: string[] = [`调试 ${this.fmtDateCn(dateMs)}`, `visibleKeys=${visibleKeys.join(',')}`];
|
const lines: string[] = [`调试 ${this.fmtDateCn(dateMs)}`, `visibleKeys=${visibleKeys.join(',')}`];
|
||||||
let idx: number = 0;
|
let idx: number = 0;
|
||||||
|
let netChecked: boolean = false;
|
||||||
for (const m of masters.values()) {
|
for (const m of masters.values()) {
|
||||||
if (idx >= 6) {
|
if (idx >= 6) {
|
||||||
lines.push('…(更多系列省略)');
|
lines.push('…(更多系列省略)');
|
||||||
@@ -939,13 +961,54 @@ struct Index {
|
|||||||
lines.push(` rrule=${m.rrule === '' ? '(空!)' : m.rrule}`);
|
lines.push(` rrule=${m.rrule === '' ? '(空!)' : m.rrule}`);
|
||||||
lines.push(` exdate=${m.exdate === '' ? '无' : m.exdate}`);
|
lines.push(` exdate=${m.exdate === '' ? '无' : m.exdate}`);
|
||||||
lines.push(` 展开条数=${occs.length} 含首次=${hasFirst ? '是' : '否'} 首次=${occs.length > 0 ? fmt(occs[0]) : '无'} 该日发生=${hitsDay ? '是' : '否'}`);
|
lines.push(` 展开条数=${occs.length} 含首次=${hasFirst ? '是' : '否'} 首次=${occs.length > 0 ? fmt(occs[0]) : '无'} 该日发生=${hitsDay ? '是' : '否'}`);
|
||||||
|
lines.push(` 数据库提醒=${m.reminder}分钟 DB同UID行数=${rows.filter((r: LocalEvent): boolean => r.uid === m.uid).length}`);
|
||||||
|
// 网络诊断(仅第一个命中该日的系列):GET 服务器原始 ICS,看 VALARM 是否还在
|
||||||
|
if (hitsDay && !netChecked) {
|
||||||
|
netChecked = true;
|
||||||
|
try {
|
||||||
|
const accounts2: DavAccount[] = await AccountStore.loadAll(context);
|
||||||
|
const acc2 = accounts2.find((a: DavAccount): boolean => a.calendarHrefs.includes(m.href));
|
||||||
|
if (acc2 !== undefined) {
|
||||||
|
const auth2: string = DavClient.authHeader(acc2.username, acc2.password);
|
||||||
|
const url: string = m.href.endsWith('/')
|
||||||
|
? m.href + m.remotePath : `${m.href}/${m.remotePath}`;
|
||||||
|
const raw: string = await DavClient.getRaw(url, auth2);
|
||||||
|
const upper: string = raw.toUpperCase();
|
||||||
|
const trigIdx: number = upper.indexOf('TRIGGER');
|
||||||
|
lines.push(` 服务器ICS:${raw.length}字节 VALARM=${upper.includes('BEGIN:VALARM') ? '有' : '无'}` +
|
||||||
|
(trigIdx >= 0 ? ` TRIGGER=${raw.substring(trigIdx, trigIdx + 40).replace(/\r?\n/g, ' ')}` : ''));
|
||||||
|
// 用解析函数当场重解析这份 ICS,验证解析是否丢提醒
|
||||||
|
const reParsed: RemoteEvent[] = IcsUtil.parse(raw);
|
||||||
|
const hit = reParsed.find((p: RemoteEvent): boolean => p.uid === m.uid);
|
||||||
|
lines.push(` 重新解析:提醒=${hit !== undefined ? hit.reminder : '?'}分钟 rec=${hit !== undefined ? (hit.recurring ? 1 : 0) : '?'} rrule=${hit !== undefined && hit.rrule !== '' ? hit.rrule : '无'}`);
|
||||||
|
// 模拟同步路径:REPORT 拉取(与 SyncEngine 完全一致)再解析
|
||||||
|
try {
|
||||||
|
const repItems: RemoteItem[] = await DavClient.reportCalendar(m.href, auth2);
|
||||||
|
const repIt = repItems.find((x: RemoteItem): boolean => x.ics.includes(m.uid));
|
||||||
|
if (repIt !== undefined) {
|
||||||
|
const rp: RemoteEvent[] = IcsUtil.parse(repIt.ics);
|
||||||
|
const hit2 = rp.find((p: RemoteEvent): boolean => p.uid === m.uid);
|
||||||
|
lines.push(` REPORT重解析:提醒=${hit2 !== undefined ? hit2.reminder : '?'}分钟 VALARM=${repIt.ics.toUpperCase().includes('BEGIN:VALARM') ? '有' : '无'} 字节=${repIt.ics.length}`);
|
||||||
|
lines.push(` etag对比:DB=${m.etag === '' ? '(空)' : m.etag.substring(0, 16)} REPORT=${repIt.etag === '' ? '(空)' : repIt.etag.substring(0, 16)}`);
|
||||||
|
} else {
|
||||||
|
lines.push(` REPORT重解析:未找到该UID(共${repItems.length}条)`);
|
||||||
|
}
|
||||||
|
} catch (err2) {
|
||||||
|
lines.push(' REPORT拉取失败:同步可能一直在此失败!');
|
||||||
|
}
|
||||||
|
lines.push(` ICS原文:${raw.substring(0, 380).replace(/\r?\n/g, ' ⏎ ')}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
lines.push(' 服务器ICS获取失败(网络)');
|
||||||
|
}
|
||||||
|
}
|
||||||
const ovs = overrides.get(m.uid) ?? [];
|
const ovs = overrides.get(m.uid) ?? [];
|
||||||
if (ovs.length === 0) {
|
if (ovs.length === 0) {
|
||||||
lines.push(' 覆盖实例: 无');
|
lines.push(' 覆盖实例: 无');
|
||||||
} else {
|
} else {
|
||||||
for (const o of ovs.slice(0, 5)) {
|
for (const o of ovs.slice(0, 5)) {
|
||||||
const oVis: boolean = visibleKeys.includes(o.calKey);
|
const oVis: boolean = visibleKeys.includes(o.calKey);
|
||||||
lines.push(` 覆盖: ${fmt(o.startTime)}~${fmt(o.endTime)} rec=${o.recurring ? 1 : 0} calKey=${o.calKey} vis=${oVis ? 1 : 0} dirty=${o.dirty ? 1 : 0}`);
|
lines.push(` 覆盖: ${fmt(o.startTime)}~${fmt(o.endTime)} rec=${o.recurring ? 1 : 0} calKey=${o.calKey} vis=${oVis ? 1 : 0} dirty=${o.dirty ? 1 : 0} 提醒=${o.reminder}分钟`);
|
||||||
}
|
}
|
||||||
if (ovs.length > 5) {
|
if (ovs.length > 5) {
|
||||||
lines.push(` 覆盖共 ${ovs.length} 条`);
|
lines.push(` 覆盖共 ${ovs.length} 条`);
|
||||||
@@ -955,6 +1018,18 @@ struct Index {
|
|||||||
if (lines.length <= 2) {
|
if (lines.length <= 2) {
|
||||||
lines.push('(该日期附近没有重复主事件)');
|
lines.push('(该日期附近没有重复主事件)');
|
||||||
}
|
}
|
||||||
|
// 当日非重复日程(含覆盖实例):核对 DB 中实际存储的提醒值,定位提醒丢失环节
|
||||||
|
const plain: LocalEvent[] = rows.filter((r: LocalEvent): boolean =>
|
||||||
|
r.rrule === '' && r.startTime < dayEnd && r.endTime >= dayStart);
|
||||||
|
if (plain.length > 0) {
|
||||||
|
lines.push(`—— 当日非重复日程 ${plain.length} 条 ——`);
|
||||||
|
for (const r of plain.slice(0, 8)) {
|
||||||
|
lines.push(`◇ ${r.title === '' ? '(无标题)' : r.title} 提醒=${r.reminder}分钟 rec=${r.recurring ? 1 : 0} calKey=${r.calKey}`);
|
||||||
|
}
|
||||||
|
if (plain.length > 8) {
|
||||||
|
lines.push(`…(其余 ${plain.length - 8} 条省略)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
const text: string = lines.join('\n').substring(0, 3500);
|
const text: string = lines.join('\n').substring(0, 3500);
|
||||||
this.getUIContext().showAlertDialog({
|
this.getUIContext().showAlertDialog({
|
||||||
title: '重复日程调试',
|
title: '重复日程调试',
|
||||||
|
|||||||
@@ -210,6 +210,36 @@ struct SettingsPage {
|
|||||||
return minutes >= 60 ? `${minutes / 60} 小时` : `${minutes} 分钟`;
|
return minutes >= 60 ? `${minutes / 60} 小时` : `${minutes} 分钟`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 确认后重置全量重拉标记,下次同步重建全部日程数据 */
|
||||||
|
private askRebuildData(): void {
|
||||||
|
this.getUIContext().showAlertDialog({
|
||||||
|
title: '重建日程数据',
|
||||||
|
message: '将重新从服务器拉取所有日程的完整数据(包括提醒),本地已修改未同步的内容不受影响。\n\n全量拉取可能需要几分钟,期间请保持 App 在前台、不要熄屏。',
|
||||||
|
autoCancel: true,
|
||||||
|
alignment: DialogAlignment.Center,
|
||||||
|
primaryButton: {
|
||||||
|
value: '取消',
|
||||||
|
action: (): void => {}
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
value: '开始重建',
|
||||||
|
action: (): void => {
|
||||||
|
this.doRebuildData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doRebuildData(): Promise<void> {
|
||||||
|
if (this.context === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await AppSettings.resetFullRefetch(this.context);
|
||||||
|
this.getUIContext().getPromptAction().showToast({
|
||||||
|
message: '已开启,返回首页后点一次同步即可,请保持屏幕常亮'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
Column() {
|
Column() {
|
||||||
// 顶部
|
// 顶部
|
||||||
@@ -349,6 +379,32 @@ struct SettingsPage {
|
|||||||
.backgroundColor($r('app.color.card_bg'))
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||||||
|
|
||||||
|
// 数据修复:手动触发一次性全量重拉(重建提醒等本地残缺字段)
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
Column({ space: 2 }) {
|
||||||
|
Text('重建日程数据')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text('同步时重新从服务器拉取全部日程的完整数据,修复提醒丢失等本地数据问题。耗时段落请保持 App 在前台、屏幕常亮')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
.layoutWeight(1)
|
||||||
|
Button('重建')
|
||||||
|
.fontSize(13)
|
||||||
|
.backgroundColor($r('app.color.brand'))
|
||||||
|
.onClick(() => {
|
||||||
|
this.askRebuildData();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.borderRadius(12)
|
||||||
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
|
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||||||
|
|
||||||
// 混合显示系统日历
|
// 混合显示系统日历
|
||||||
Row({ space: 10 }) {
|
Row({ space: 10 }) {
|
||||||
Column({ space: 2 }) {
|
Column({ space: 2 }) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { SyncEngine } from '../common/SyncEngine';
|
|||||||
import { CardDataService } from '../common/CardDataService';
|
import { CardDataService } from '../common/CardDataService';
|
||||||
import { ReminderService } from '../common/ReminderService';
|
import { ReminderService } from '../common/ReminderService';
|
||||||
import { SystemCalendarImport } from '../common/SystemCalendarImport';
|
import { SystemCalendarImport } from '../common/SystemCalendarImport';
|
||||||
|
import { AppSettings } from '../common/AppSettings';
|
||||||
import { LogUtil } from '../common/LogUtil';
|
import { LogUtil } from '../common/LogUtil';
|
||||||
|
|
||||||
export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
|
export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
|
||||||
@@ -32,19 +33,25 @@ export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
|
|||||||
// 导入失败不影响正常同步
|
// 导入失败不影响正常同步
|
||||||
}
|
}
|
||||||
let ok: number = 0;
|
let ok: number = 0;
|
||||||
|
let attempted: number = 0;
|
||||||
for (const acc of accounts) {
|
for (const acc of accounts) {
|
||||||
if (acc.type !== TYPE_CALDAV) {
|
if (acc.type !== TYPE_CALDAV) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
attempted++;
|
||||||
try {
|
try {
|
||||||
await SyncEngine.withTimeout(
|
// 超时保护由 syncAccount 内部处理(全量重拉 10 分钟 / 常规 5 分钟),不再套外层超时
|
||||||
SyncEngine.syncAccount(context, acc), 100000);
|
await SyncEngine.syncAccount(context, acc);
|
||||||
ok++;
|
ok++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as BusinessError;
|
const e = err as BusinessError;
|
||||||
LogUtil.write(`延迟任务同步「${acc.name}」失败: ${e.message}`);
|
LogUtil.write(`延迟任务同步「${acc.name}」失败: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 全部账号同步成功才结束"一次性全量重拉"状态
|
||||||
|
if (attempted > 0 && ok === attempted) {
|
||||||
|
await AppSettings.markFullRefetchDone(context);
|
||||||
|
}
|
||||||
await SyncEngine.settleLocalEvents(context);
|
await SyncEngine.settleLocalEvents(context);
|
||||||
await SyncEngine.pruneOrphanRows(context, accounts);
|
await SyncEngine.pruneOrphanRows(context, accounts);
|
||||||
// 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见
|
// 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见
|
||||||
|
|||||||
Reference in New Issue
Block a user