首次提交:SyncCalendar 项目
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
// entry/src/main/ets/common/AccountStore.ets
|
||||
import { preferences } from '@kit.ArkData';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
|
||||
export const TYPE_CALDAV: string = 'caldav';
|
||||
export const TYPE_CARDDAV: string = 'carddav';
|
||||
export const TYPE_WEBDAV: string = 'webdav';
|
||||
export const TYPE_KEYS: string[] = [TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV];
|
||||
|
||||
/** 本机事件所属的虚拟日历 key */
|
||||
export const LOCAL_CAL_KEY: string = 'local';
|
||||
|
||||
/**
|
||||
* DAV 账号(@Observed 使同步状态变化能刷新列表 UI)
|
||||
*/
|
||||
@Observed
|
||||
export class DavAccount {
|
||||
id: string = '';
|
||||
type: string = TYPE_CALDAV;
|
||||
name: string = '';
|
||||
serverUrl: string = '';
|
||||
username: string = '';
|
||||
password: string = '';
|
||||
calendarHrefs: string[] = [];
|
||||
calendarNames: string[] = [];
|
||||
/** 服务器端定义的日历本颜色(calendar-color),与 calendarHrefs 一一对应,空串表示未定义 */
|
||||
calendarColors: string[] = [];
|
||||
itemCount: number = 0;
|
||||
lastSyncTime: string = '';
|
||||
}
|
||||
|
||||
/** 日历本来源(用于界面显隐与取色) */
|
||||
export class CalSource {
|
||||
calKey: string = '';
|
||||
name: string = '';
|
||||
color: string = '#007DFF';
|
||||
source: string = 'dav'; // 'dav' | 'system' | 'local'
|
||||
visible: boolean = true;
|
||||
}
|
||||
|
||||
/** 与 UI 无关的调色板 */
|
||||
export class BookPalette {
|
||||
static colors: string[] = [
|
||||
'#007DFF', '#FF7D00', '#00B96B', '#9F44FF', '#F5317F', '#00B2C6', '#B58600', '#6D4AFF'
|
||||
];
|
||||
static colorFor(index: number): string {
|
||||
return BookPalette.colors[index % BookPalette.colors.length];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号持久化:每个账号编码为一个分隔符字符串存储(acc_0、acc_1…),避免 JSON 结构化类型问题
|
||||
*/
|
||||
export class AccountStore {
|
||||
private static readonly STORE: string = 'caldav_account';
|
||||
private static readonly COUNT_KEY: string = 'accountCount';
|
||||
|
||||
/** 规范化颜色:#RRGGBBAA → #RRGGBB;非法返回空串 */
|
||||
static normalizeColor(raw: string): string {
|
||||
const v: string = raw.trim();
|
||||
if (/^#[0-9A-Fa-f]{8}$/.test(v)) {
|
||||
return '#' + v.substring(3, 9);
|
||||
}
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(v)) {
|
||||
return v.toUpperCase();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private static encodeAccount(acc: DavAccount): string {
|
||||
const safe = (s: string): string => s.split('|').join('∥');
|
||||
const parts: string[] = [
|
||||
safe(acc.type),
|
||||
safe(acc.name),
|
||||
safe(acc.serverUrl),
|
||||
safe(acc.username),
|
||||
safe(acc.password),
|
||||
String(acc.itemCount),
|
||||
safe(acc.lastSyncTime),
|
||||
acc.calendarHrefs.join(';'),
|
||||
acc.calendarNames.join(';'),
|
||||
acc.calendarColors.join(';'),
|
||||
safe(acc.id)
|
||||
];
|
||||
return parts.join('|');
|
||||
}
|
||||
|
||||
private static decodeAccount(raw: string): DavAccount | null {
|
||||
const parts: string[] = raw.split('|');
|
||||
if (parts.length < 9) {
|
||||
return null;
|
||||
}
|
||||
const acc = new DavAccount();
|
||||
acc.type = parts[0] === '' ? TYPE_CALDAV : parts[0];
|
||||
acc.name = parts[1];
|
||||
acc.serverUrl = parts[2];
|
||||
acc.username = parts[3];
|
||||
acc.password = parts[4];
|
||||
const count: number = Number(parts[5]);
|
||||
acc.itemCount = Number.isNaN(count) ? 0 : count;
|
||||
acc.lastSyncTime = parts[6];
|
||||
acc.calendarHrefs = parts[7] === '' ? [] : parts[7].split(';');
|
||||
acc.calendarNames = parts[8] === '' ? [] : parts[8].split(';');
|
||||
if (parts.length >= 10) {
|
||||
acc.calendarColors = parts[9] === '' ? [] : parts[9].split(';');
|
||||
}
|
||||
// 兼容旧版本存储(无 id 字段):加载时为空,由 loadAll 统一补发并持久化
|
||||
if (parts.length >= 11) {
|
||||
acc.id = parts[10];
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
static async loadAll(context: common.Context): Promise<DavAccount[]> {
|
||||
const result: DavAccount[] = [];
|
||||
let migrated: boolean = false;
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AccountStore.STORE);
|
||||
const count: number = await store.get(AccountStore.COUNT_KEY, 0) as number;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const raw = await store.get(`acc_${i}`, '') as string;
|
||||
if (raw === '') {
|
||||
continue;
|
||||
}
|
||||
const acc = AccountStore.decodeAccount(raw);
|
||||
if (acc !== null) {
|
||||
if (acc.id === '') {
|
||||
// 旧版本存储没有 id:补发一个并标记需要回写,保证 id 跨启动稳定
|
||||
acc.id = `acc${Date.now()}_${i}`;
|
||||
migrated = true;
|
||||
}
|
||||
result.push(acc);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`读取账号失败: ${e.message}`);
|
||||
}
|
||||
if (migrated) {
|
||||
try {
|
||||
await AccountStore.saveAll(context, result);
|
||||
console.info('旧版账号数据已迁移:补充持久化账号 id');
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`账号 id 迁移回写失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static async saveAll(context: common.Context, accounts: DavAccount[]): Promise<void> {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AccountStore.STORE);
|
||||
const oldCount: number = await store.get(AccountStore.COUNT_KEY, 0) as number;
|
||||
for (let i = 0; i < oldCount; i++) {
|
||||
store.delete(`acc_${i}`);
|
||||
}
|
||||
for (let i = 0; i < accounts.length; i++) {
|
||||
await store.put(`acc_${i}`, AccountStore.encodeAccount(accounts[i]));
|
||||
}
|
||||
await store.put(AccountStore.COUNT_KEY, accounts.length);
|
||||
await store.flush();
|
||||
}
|
||||
|
||||
static async addAccount(context: common.Context, acc: DavAccount): Promise<void> {
|
||||
const list: DavAccount[] = await AccountStore.loadAll(context);
|
||||
list.push(acc);
|
||||
await AccountStore.saveAll(context, list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// entry/src/main/ets/common/AppSettings.ets
|
||||
// 应用级设置(preferences 持久化)
|
||||
import { preferences } from '@kit.ArkData';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
|
||||
export class AppSettings {
|
||||
private static readonly STORE: string = 'sync_settings';
|
||||
private static readonly KEY_SHOW_SYSTEM: string = 'show_system_calendar';
|
||||
private static readonly KEY_SYNC_INTERVAL: string = 'sync_interval_minutes';
|
||||
|
||||
/** 是否混合显示系统日历日程(默认开) */
|
||||
static async getShowSystemCalendar(context: common.Context): Promise<boolean> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
return await store.get(AppSettings.KEY_SHOW_SYSTEM, true) as boolean;
|
||||
} catch (err) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
static async setShowSystemCalendar(context: common.Context, value: boolean): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_SHOW_SYSTEM, value);
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存设置失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 自动同步间隔(分钟),默认 1 分钟 */
|
||||
static async getSyncIntervalMinutes(context: common.Context): Promise<number> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
const v: number = await store.get(AppSettings.KEY_SYNC_INTERVAL, 1) as number;
|
||||
return v > 0 ? v : 1;
|
||||
} catch (err) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
static async setSyncIntervalMinutes(context: common.Context, minutes: number): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_SYNC_INTERVAL, minutes);
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存同步间隔失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// entry/src/main/ets/common/CalendarDataService.ets
|
||||
// 合并数据源:本地库(DAV 同步 + 本机事件) + 系统日历(只读展示)
|
||||
import { common, abilityAccessCtrl, Permissions } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { calendarManager } from '@kit.CalendarKit';
|
||||
import { AccountStore, CalSource, BookPalette, LOCAL_CAL_KEY, DavAccount } from './AccountStore';
|
||||
import { EventDb, LocalEvent } from './EventDb';
|
||||
import { LogUtil } from './LogUtil';
|
||||
import { AppSettings } from './AppSettings';
|
||||
import { RruleUtil } from './RruleUtil';
|
||||
import { IcsUtil } from './IcsUtil';
|
||||
|
||||
/** 界面展示用事件 */
|
||||
export class DisplayEvent {
|
||||
id: number = 0; // 本地库 id 或系统日历事件 id
|
||||
isSystem: boolean = false;
|
||||
title: string = '';
|
||||
description: string = '';
|
||||
location: string = '';
|
||||
startTime: number = 0;
|
||||
endTime: number = 0;
|
||||
isAllDay: boolean = false;
|
||||
calKey: string = '';
|
||||
calName: string = '';
|
||||
color: string = '#007DFF';
|
||||
completed: boolean = false; // 仅待办使用
|
||||
recurring: boolean = false; // 是否为重复日程展开出的发生
|
||||
}
|
||||
|
||||
export class CalendarDataService {
|
||||
/** 申请读取全部日历权限(混合展示系统日历需要) */
|
||||
static async ensureSystemCalendarPermission(context: common.UIAbilityContext): Promise<boolean> {
|
||||
try {
|
||||
const atManager = abilityAccessCtrl.createAtManager();
|
||||
const permissions: Permissions[] = [
|
||||
'ohos.permission.READ_CALENDAR',
|
||||
'ohos.permission.READ_WHOLE_CALENDAR'
|
||||
];
|
||||
const result = await atManager.requestPermissionsFromUser(context, permissions);
|
||||
for (const r of result.authResults) {
|
||||
if (r !== 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`日历权限申请失败: ${e.code} - ${e.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 收集全部日历来源:DAV 日历本 + 本机 + 系统日历账户 */
|
||||
static async loadSources(context: common.Context): Promise<CalSource[]> {
|
||||
const sources: CalSource[] = [];
|
||||
try {
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
for (const acc of accounts) {
|
||||
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
||||
const s = new CalSource();
|
||||
s.calKey = `${acc.id}_${i}`;
|
||||
let name: string = i < acc.calendarNames.length ? acc.calendarNames[i] : '';
|
||||
if (name === '') {
|
||||
name = acc.calendarHrefs.length === 1 ? acc.name : `日历本 ${i + 1}`;
|
||||
}
|
||||
s.name = `${acc.name}:${name}`;
|
||||
// 优先使用服务器端定义的颜色,未定义时按序号取调色板
|
||||
let color: string = i < acc.calendarColors.length ? AccountStore.normalizeColor(acc.calendarColors[i]) : '';
|
||||
s.color = color !== '' ? color : BookPalette.colorFor(i);
|
||||
s.source = 'dav';
|
||||
s.visible = true;
|
||||
sources.push(s);
|
||||
}
|
||||
}
|
||||
// 本机事件虚拟日历
|
||||
const local = new CalSource();
|
||||
local.calKey = LOCAL_CAL_KEY;
|
||||
local.name = '本机';
|
||||
local.color = '#5A6068';
|
||||
local.source = 'local';
|
||||
local.visible = true;
|
||||
sources.push(local);
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`加载 DAV 来源失败: ${e.message}`);
|
||||
}
|
||||
// 系统日历账户(受"混合显示系统日历"开关控制;按账户名去重)
|
||||
const showSys: boolean = await AppSettings.getShowSystemCalendar(context);
|
||||
if (!showSys) {
|
||||
LogUtil.write('来源加载:系统日历混合显示已关闭,跳过全部系统日历');
|
||||
} else {
|
||||
try {
|
||||
const uiContext = context as common.UIAbilityContext;
|
||||
const mgr: calendarManager.CalendarManager = calendarManager.getCalendarManager(uiContext);
|
||||
const calendars: calendarManager.Calendar[] = await mgr.getAllCalendars();
|
||||
let sysRaw: number = 0;
|
||||
for (const cal of calendars) {
|
||||
try {
|
||||
const account: calendarManager.CalendarAccount = cal.getAccount();
|
||||
sysRaw++;
|
||||
const sysKey: string = `sys_${account.name}`;
|
||||
if (sources.some((x: CalSource): boolean => x.calKey === sysKey)) {
|
||||
continue; // 同名账户只保留一个来源
|
||||
}
|
||||
const s = new CalSource();
|
||||
s.calKey = sysKey;
|
||||
s.name = '系统';
|
||||
let color = '#9AA0A6';
|
||||
const config: calendarManager.CalendarConfig = cal.getConfig();
|
||||
if (typeof config.color === 'string') {
|
||||
color = config.color;
|
||||
}
|
||||
s.color = color;
|
||||
s.source = 'system';
|
||||
s.visible = true;
|
||||
sources.push(s);
|
||||
} catch (err) {
|
||||
// 单个日历账户读取失败不影响整体
|
||||
}
|
||||
}
|
||||
LogUtil.write(`来源加载:系统日历 ${sysRaw} 个(按账户去重后保留,共 ${sources.length} 个来源)`);
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.info(`系统日历不可用(可能未授权): ${e.code}`);
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
/** 加载时间区间内的合并事件(按来源显隐过滤) */
|
||||
static async loadEvents(context: common.Context, start: number, end: number,
|
||||
sources: CalSource[]): Promise<DisplayEvent[]> {
|
||||
LogUtil.init(context);
|
||||
const result: DisplayEvent[] = [];
|
||||
const visibleKeys: string[] = sources.filter((s: CalSource): boolean => s.visible)
|
||||
.map((s: CalSource): string => s.calKey);
|
||||
const colorOf = (key: string): string => {
|
||||
const found = sources.find((s: CalSource): boolean => s.calKey === key);
|
||||
return found !== undefined ? found.color : '#9AA0A6';
|
||||
};
|
||||
const nameOf = (key: string): string => {
|
||||
const found = sources.find((s: CalSource): boolean => s.calKey === key);
|
||||
return found !== undefined ? found.name : '';
|
||||
};
|
||||
|
||||
// 1) 本地库(DAV + 本机);重复日程(RRULE)按规则展开为窗口内的多次发生
|
||||
try {
|
||||
const rows: LocalEvent[] = await EventDb.queryRange(context, start, end);
|
||||
// 重复主事件的首次发生可能在窗口外,也纳入(仅用于展开)
|
||||
const masters: LocalEvent[] = await EventDb.queryRecurringMasters(context, end);
|
||||
for (const m of masters) {
|
||||
if (!rows.some((r: LocalEvent): boolean => r.id === m.id)) {
|
||||
rows.push(m);
|
||||
}
|
||||
}
|
||||
// 单次覆盖实例(RECURRENCE-ID 独立行):展开时跳过对应发生,避免重复
|
||||
const overrideKeys: string[] = [];
|
||||
for (const e of rows) {
|
||||
if (e.rrule === '' && e.recurring) {
|
||||
overrideKeys.push(`${e.uid}_${e.startTime}`);
|
||||
}
|
||||
}
|
||||
let occTotal: number = 0;
|
||||
for (const e of rows) {
|
||||
if (!visibleKeys.includes(e.calKey)) {
|
||||
continue;
|
||||
}
|
||||
const baseColor: string = colorOf(e.calKey);
|
||||
const baseName: string = nameOf(e.calKey);
|
||||
const dur: number = Math.max(0, e.endTime - e.startTime);
|
||||
let times: number[] = [e.startTime];
|
||||
if (e.rrule !== '') {
|
||||
const exNums: number[] = [];
|
||||
if (e.exdate !== '') {
|
||||
for (const raw of e.exdate.split(';')) {
|
||||
const t = IcsUtil.parseTime(raw, !raw.includes('T'));
|
||||
if (t !== null) {
|
||||
exNums.push(t.time);
|
||||
}
|
||||
}
|
||||
}
|
||||
const occs: number[] = RruleUtil.expand(e.rrule, e.startTime, start, end, exNums, 1500);
|
||||
if (occs.length > 0) {
|
||||
times = occs.filter((occ: number): boolean => !overrideKeys.includes(`${e.uid}_${occ}`));
|
||||
}
|
||||
}
|
||||
for (const occ of times) {
|
||||
const d = new DisplayEvent();
|
||||
d.id = e.id;
|
||||
d.isSystem = false;
|
||||
d.title = e.title;
|
||||
d.description = e.description;
|
||||
d.location = e.location;
|
||||
d.startTime = occ;
|
||||
d.endTime = occ + dur;
|
||||
d.isAllDay = e.isAllDay;
|
||||
d.calKey = e.calKey;
|
||||
d.calName = baseName;
|
||||
d.color = baseColor;
|
||||
d.recurring = e.rrule !== '';
|
||||
result.push(d);
|
||||
occTotal++;
|
||||
}
|
||||
}
|
||||
LogUtil.write(`显示加载:本地库 ${rows.length} 行,展开后 ${occTotal} 条(区间 ${new Date(start).toLocaleString()} ~ ${new Date(end).toLocaleString()})`);
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`读取本地事件失败: ${e.message}`);
|
||||
}
|
||||
|
||||
// 2) 系统日历(只读混合展示)——单日历失败只跳过该日历,不影响其余
|
||||
if (visibleKeys.some((k: string): boolean => k.startsWith('sys_'))) {
|
||||
try {
|
||||
const uiContext = context as common.UIAbilityContext;
|
||||
const mgr: calendarManager.CalendarManager = calendarManager.getCalendarManager(uiContext);
|
||||
const calendars: calendarManager.Calendar[] = await mgr.getAllCalendars();
|
||||
for (const cal of calendars) {
|
||||
let accountName: string = '';
|
||||
let color = '#9AA0A6';
|
||||
try {
|
||||
const account: calendarManager.CalendarAccount = cal.getAccount();
|
||||
accountName = account.name;
|
||||
const config: calendarManager.CalendarConfig = cal.getConfig();
|
||||
if (typeof config.color === 'string') {
|
||||
color = config.color;
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略单个日历
|
||||
}
|
||||
const sysKey: string = `sys_${accountName}`;
|
||||
if (!visibleKeys.includes(sysKey)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const events: calendarManager.Event[] = await cal.queryEventInstances(start, end);
|
||||
let skipped: number = 0;
|
||||
for (const ev of events) {
|
||||
const d = new DisplayEvent();
|
||||
d.id = ev.id !== undefined ? ev.id : 0;
|
||||
d.isSystem = true;
|
||||
d.title = ev.title !== undefined ? ev.title : '(无标题)';
|
||||
d.description = ev.description !== undefined ? ev.description : '';
|
||||
d.location = ev.location !== undefined && ev.location.location !== undefined ? ev.location.location : '';
|
||||
d.startTime = ev.startTime;
|
||||
d.endTime = ev.endTime;
|
||||
d.isAllDay = ev.isAllDay === true;
|
||||
d.calKey = sysKey;
|
||||
d.calName = nameOf(sysKey);
|
||||
d.color = color;
|
||||
// 去重:与本地(DAV/本机)同标题+同时间的视为同一条,避免两个来源重复展示
|
||||
const dup: boolean = result.some((x: DisplayEvent): boolean => !x.isSystem &&
|
||||
x.title === d.title && x.startTime === d.startTime &&
|
||||
x.endTime === d.endTime && x.isAllDay === d.isAllDay);
|
||||
if (!dup) {
|
||||
result.push(d);
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
LogUtil.write(`系统日历 ${sysKey}:查询到 ${events.length} 条,与本地重复跳过 ${skipped} 条`);
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.info(`查询系统日历 ${sysKey} 失败(跳过): ${e.code}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.info(`读取系统日历失败(可能未授权): ${e.code}`);
|
||||
}
|
||||
}
|
||||
// 排序:全天日程最前,然后按开始时间
|
||||
result.sort((a: DisplayEvent, b: DisplayEvent): number => {
|
||||
if (a.isAllDay !== b.isAllDay) {
|
||||
return a.isAllDay ? -1 : 1;
|
||||
}
|
||||
return a.startTime - b.startTime;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 加载全部待办(VTODO,来自 DAV 同步,只读展示) */
|
||||
static async loadTodos(context: common.Context, sources: CalSource[]): Promise<DisplayEvent[]> {
|
||||
const result: DisplayEvent[] = [];
|
||||
const visibleKeys: string[] = sources.filter((s: CalSource): boolean => s.visible)
|
||||
.map((s: CalSource): string => s.calKey);
|
||||
const colorOf = (key: string): string => {
|
||||
const found = sources.find((s: CalSource): boolean => s.calKey === key);
|
||||
return found !== undefined ? found.color : '#9AA0A6';
|
||||
};
|
||||
try {
|
||||
const rows: LocalEvent[] = await EventDb.queryTodos(context);
|
||||
for (const e of rows) {
|
||||
if (!visibleKeys.includes(e.calKey)) {
|
||||
continue;
|
||||
}
|
||||
const d = new DisplayEvent();
|
||||
d.id = e.id;
|
||||
d.isSystem = false;
|
||||
d.title = e.title;
|
||||
d.description = e.description;
|
||||
d.location = e.location;
|
||||
d.startTime = e.startTime;
|
||||
d.endTime = e.endTime;
|
||||
d.isAllDay = e.isAllDay;
|
||||
d.calKey = e.calKey;
|
||||
d.color = colorOf(e.calKey);
|
||||
d.completed = e.completed;
|
||||
result.push(d);
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`读取待办失败: ${e.message}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// entry/src/main/ets/common/CardDataService.ets
|
||||
// 服务卡片数据:从本地库取"今天起"的日程(含 RRULE 展开),输出 JSON 给卡片渲染
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { formBindingData, formProvider } from '@kit.FormKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { preferences } from '@kit.ArkData';
|
||||
import { CalendarDataService, DisplayEvent } from './CalendarDataService';
|
||||
import { LunarUtil } from './LunarUtil';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
/** 卡片单条日程(按天分组:组内全天事件在前、有时间的按开始时间排序) */
|
||||
export class CardItem {
|
||||
title: string = '';
|
||||
time: string = ''; // 开始时间 '08:30' / '全天'
|
||||
endTime: string = ''; // 结束时间 '10:00'(全天事件为空)
|
||||
date: string = ''; // '9月15日'
|
||||
showDate: boolean = false; // 是否为当天分组的第一条(卡片上渲染日期头)
|
||||
calName: string = ''; // 所属日历本名(右侧显示,颜色同日历色)
|
||||
color: string = '#007DFF';
|
||||
}
|
||||
|
||||
/** 卡片整体数据 */
|
||||
export class CardData {
|
||||
eventsJson: string = '[]';
|
||||
dateText: string = '';
|
||||
lunarText: string = '';
|
||||
}
|
||||
|
||||
export class CardDataService {
|
||||
private static readonly MAX_ITEMS: number = 50;
|
||||
// 已添加卡片的 formId 注册表(同进程内有效)
|
||||
private static formIds: string[] = [];
|
||||
// formIds 持久化(App 重启后内存注册表会清空,从 preferences 恢复,保证推送不丢卡片)
|
||||
private static readonly PREF_STORE: string = 'card_form_ids';
|
||||
private static readonly PREF_KEY: string = 'form_ids';
|
||||
|
||||
/** 注册卡片:内存 + 持久化 */
|
||||
static async registerForm(context: common.Context, formId: string): Promise<void> {
|
||||
if (formId !== '' && !CardDataService.formIds.includes(formId)) {
|
||||
CardDataService.formIds.push(formId);
|
||||
}
|
||||
await CardDataService.saveFormIds(context);
|
||||
}
|
||||
|
||||
/** 注销卡片:内存 + 持久化 */
|
||||
static async unregisterForm(context: common.Context, formId: string): Promise<void> {
|
||||
CardDataService.formIds = CardDataService.formIds.filter((id: string): boolean => id !== formId);
|
||||
await CardDataService.saveFormIds(context);
|
||||
}
|
||||
|
||||
private static async saveFormIds(context: common.Context): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, CardDataService.PREF_STORE);
|
||||
await store.put(CardDataService.PREF_KEY, CardDataService.formIds.join(','));
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存 formIds 失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private static async loadPersistedFormIds(context: common.Context): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, CardDataService.PREF_STORE);
|
||||
const raw: string = await store.get(CardDataService.PREF_KEY, '') as string;
|
||||
for (const id of raw.split(',')) {
|
||||
if (id !== '' && !CardDataService.formIds.includes(id)) {
|
||||
CardDataService.formIds.push(id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 读取失败则仅用内存注册表
|
||||
}
|
||||
}
|
||||
|
||||
/** App 内同步完成后调用:刷新所有已添加的卡片 */
|
||||
static async pushToAllForms(context: common.Context): Promise<void> {
|
||||
// 先恢复持久化的 formId(App 重启后内存注册表为空)
|
||||
await CardDataService.loadPersistedFormIds(context);
|
||||
if (CardDataService.formIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const data: CardData = await CardDataService.buildCardData(context);
|
||||
const binding: formBindingData.FormBindingData =
|
||||
formBindingData.createFormBindingData(data);
|
||||
const stale: string[] = [];
|
||||
for (const formId of CardDataService.formIds) {
|
||||
try {
|
||||
await formProvider.updateForm(formId, binding);
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`卡片 ${formId} 刷新失败(移除注册): ${e.message}`);
|
||||
stale.push(formId);
|
||||
}
|
||||
}
|
||||
for (const id of stale) {
|
||||
CardDataService.formIds = CardDataService.formIds.filter((x: string): boolean => x !== id);
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
await CardDataService.saveFormIds(context);
|
||||
}
|
||||
}
|
||||
|
||||
private static startOfDay(ms: number): number {
|
||||
const d = new Date(ms);
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
}
|
||||
|
||||
/** 组装卡片数据(异步:查询本地库) */
|
||||
static async buildCardData(context: common.Context): Promise<CardData> {
|
||||
const data = new CardData();
|
||||
try {
|
||||
LogUtil.init(context);
|
||||
const now = new Date();
|
||||
const weekCn: string[] = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
data.dateText = `${now.getMonth() + 1}月${now.getDate()}日 ${weekCn[now.getDay()]}`;
|
||||
data.lunarText = LunarUtil.lunarDayText(now.getTime());
|
||||
const start: number = CardDataService.startOfDay(now.getTime());
|
||||
const end: number = start + 60 * 86400000;
|
||||
const sources = await CalendarDataService.loadSources(context);
|
||||
const events: DisplayEvent[] = await CalendarDataService.loadEvents(context, start, end, sources);
|
||||
// 1) 过滤已结束超过 1 小时的;2) 按天分组,组内全天在前、有时间按开始时间排序;
|
||||
// 3) 平铺输出并给每组第一条打 showDate 标记(日期头只跟在自己日期前面)
|
||||
// 跨天日程:已开始未结束的归到"今天"、按全天显示,不再从开始那天重复显示
|
||||
const upcoming: DisplayEvent[] = events
|
||||
.filter((e: DisplayEvent): boolean => e.endTime >= now.getTime() - 3600000);
|
||||
|
||||
const todayKey: number = CardDataService.startOfDay(now.getTime());
|
||||
const dayKey = (ms: number): number => CardDataService.startOfDay(ms);
|
||||
const spansDays = (e: DisplayEvent): boolean =>
|
||||
dayKey(e.endTime) > dayKey(e.startTime);
|
||||
const groups = new Map<number, DisplayEvent[]>();
|
||||
for (const e of upcoming) {
|
||||
// 跨天且已开始:归今天;其余归开始日
|
||||
const key: number = spansDays(e) && dayKey(e.startTime) < todayKey
|
||||
? todayKey : dayKey(e.startTime);
|
||||
const arr = groups.get(key);
|
||||
if (arr === undefined) {
|
||||
groups.set(key, [e]);
|
||||
} else {
|
||||
arr.push(e);
|
||||
}
|
||||
}
|
||||
const dayKeys: number[] = Array.from(groups.keys()).sort((a: number, b: number): number => a - b);
|
||||
|
||||
const items: CardItem[] = [];
|
||||
const p = (n: number): string => n < 10 ? '0' + n : String(n);
|
||||
outer:
|
||||
for (const key of dayKeys) {
|
||||
const list = groups.get(key) as DisplayEvent[];
|
||||
const allDay = list.filter((e: DisplayEvent): boolean =>
|
||||
e.isAllDay || spansDays(e)); // 跨天日程按全天展示
|
||||
const timed = list.filter((e: DisplayEvent): boolean =>
|
||||
!e.isAllDay && !spansDays(e))
|
||||
.sort((a: DisplayEvent, b: DisplayEvent): number => a.startTime - b.startTime);
|
||||
const ordered: DisplayEvent[] = allDay.concat(timed);
|
||||
for (let i = 0; i < ordered.length; i++) {
|
||||
if (items.length >= CardDataService.MAX_ITEMS) {
|
||||
break outer;
|
||||
}
|
||||
const e = ordered[i];
|
||||
const isDayLong: boolean = e.isAllDay || spansDays(e);
|
||||
const d = new Date(e.startTime);
|
||||
const item = new CardItem();
|
||||
item.title = e.title === '' ? '(无标题)' : e.title;
|
||||
item.time = isDayLong ? '全天' : `${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
if (isDayLong) {
|
||||
item.endTime = '';
|
||||
} else {
|
||||
const de = new Date(e.endTime);
|
||||
item.endTime = `${p(de.getHours())}:${p(de.getMinutes())}`;
|
||||
}
|
||||
item.date = `${d.getMonth() + 1}月${d.getDate()}日`;
|
||||
item.showDate = i === 0; // 当天分组第一条 → 卡片上显示日期头
|
||||
item.calName = e.calName;
|
||||
item.color = e.color;
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
data.eventsJson = JSON.stringify(items);
|
||||
LogUtil.write(`卡片数据刷新:${items.length} 条`);
|
||||
} catch (err) {
|
||||
LogUtil.write(`卡片数据刷新失败: ${JSON.stringify(err)}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// entry/src/main/ets/common/DavClient.ets
|
||||
// CalDAV HTTP 客户端:REPORT / PUT / DELETE
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { buffer } from '@kit.ArkTS';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
/** REPORT 返回的单个远端资源 */
|
||||
export class RemoteItem {
|
||||
href: string = ''; // 资源完整 URL
|
||||
etag: string = '';
|
||||
ics: string = ''; // VCALENDAR 文本
|
||||
}
|
||||
|
||||
/** PROPFIND 返回的集合颜色 */
|
||||
export class DavColorEntry {
|
||||
href: string = ''; // 集合路径(服务器返回的是路径,不带域名)
|
||||
color: string = ''; // 规范化后的 #RRGGBB,可能为空
|
||||
}
|
||||
|
||||
export class DavClient {
|
||||
/**
|
||||
* PROPFIND 拉取某路径下所有集合的 calendar-color
|
||||
* 返回「集合路径 → 颜色」列表(路径为服务器返回的原始 href)
|
||||
*/
|
||||
static async propfindColors(serverUrl: string, auth: string): Promise<DavColorEntry[]> {
|
||||
const requestBody: string = '<?xml version="1.0" encoding="utf-8"?>' +
|
||||
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
|
||||
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
|
||||
'<d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
|
||||
'</d:prop></d:propfind>';
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const resp: http.HttpResponse = await httpRequest.request(serverUrl, {
|
||||
method: 'PROPFIND' as http.RequestMethod,
|
||||
header: {
|
||||
'Authorization': auth,
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Depth': '1',
|
||||
'User-Agent': 'SyncCalendar/1.0'
|
||||
},
|
||||
extraData: requestBody,
|
||||
connectTimeout: 10000,
|
||||
readTimeout: 20000
|
||||
});
|
||||
console.info(`PROPFIND(颜色) 响应码: ${resp.responseCode}`);
|
||||
LogUtil.write(`HTTP PROPFIND(颜色) ${serverUrl} → ${resp.responseCode}`);
|
||||
if (resp.responseCode !== 207 && resp.responseCode !== 200) {
|
||||
return [];
|
||||
}
|
||||
const xml: string = resp.result as string;
|
||||
const result: DavColorEntry[] = [];
|
||||
const blocks: string[] = xml.split(/<\/[\w-]*:?response>/i);
|
||||
for (const block of blocks) {
|
||||
if (!/<[\w-]*:?response[\s>]/i.test(block)) {
|
||||
continue;
|
||||
}
|
||||
const href: string = DavClient.extractTag(block, 'href');
|
||||
if (href === '') {
|
||||
continue;
|
||||
}
|
||||
const resourcetype: string = DavClient.extractTag(block, 'resourcetype');
|
||||
if (!/calendar/i.test(resourcetype)) {
|
||||
continue;
|
||||
}
|
||||
const entry = new DavColorEntry();
|
||||
entry.href = href;
|
||||
// 两个命名空间都试:cs:getcolor(CalendarServer)/ ical:calendar-color(Apple)
|
||||
let color: string = DavClient.normalizeHex(DavClient.extractTag(block, 'getcolor'));
|
||||
if (color === '') {
|
||||
color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color'));
|
||||
}
|
||||
entry.color = color;
|
||||
result.push(entry);
|
||||
}
|
||||
if (result.length > 0 && result.every((e: DavColorEntry): boolean => e.color === '')) {
|
||||
// 全部没拿到颜色时打印原始响应片段,便于诊断命名空间
|
||||
console.info(`PROPFIND(颜色) 未取到颜色,响应片段: ${xml.substring(0, 600)}`);
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** 颜色规范化:#RRGGBBAA → #RRGGBB */
|
||||
static normalizeHex(raw: string): string {
|
||||
const v: string = raw.trim();
|
||||
if (/^#[0-9A-Fa-f]{8}$/.test(v)) {
|
||||
return '#' + v.substring(3, 9).toUpperCase();
|
||||
}
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(v)) {
|
||||
return v.toUpperCase();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Basic Auth 头 */
|
||||
static authHeader(username: string, password: string): string {
|
||||
return 'Basic ' + buffer.from(`${username}:${password}`).toString('base64');
|
||||
}
|
||||
|
||||
/** REPORT calendar-query:全量拉取某日历本内所有 VEVENT(不加时间范围,保证数据完整) */
|
||||
static async reportCalendar(href: string, auth: string): Promise<RemoteItem[]> {
|
||||
return DavClient.reportComponents(href, auth, 'VEVENT', '');
|
||||
}
|
||||
|
||||
/** REPORT calendar-query:拉取某日历本内所有 VTODO 待办(不限时间范围,量小) */
|
||||
static async reportTodos(href: string, auth: string): Promise<RemoteItem[]> {
|
||||
return DavClient.reportComponents(href, auth, 'VTODO', '');
|
||||
}
|
||||
|
||||
/** 通用 REPORT:按组件类型过滤拉取 calendar-data + getetag */
|
||||
private static async reportComponents(href: string, auth: string,
|
||||
compName: string, timeRange: 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/><c:calendar-data/></d:prop>' +
|
||||
'<c:filter><c:comp-filter name="VCALENDAR">' +
|
||||
`<c:comp-filter name="${compName}">` +
|
||||
timeRange +
|
||||
'</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: 60000
|
||||
});
|
||||
console.info(`REPORT(${compName}) ${href} 响应码: ${resp.responseCode}`);
|
||||
LogUtil.write(`HTTP REPORT(${compName}) ${href} → ${resp.responseCode}`);
|
||||
if (resp.responseCode === 401) {
|
||||
LogUtil.write(`HTTP REPORT(${compName}) 401 拒绝凭据`);
|
||||
throw new Error('服务器拒绝凭据(401)');
|
||||
}
|
||||
if (resp.responseCode < 200 || resp.responseCode >= 300) {
|
||||
LogUtil.write(`HTTP REPORT(${compName}) 异常状态码 ${resp.responseCode}`);
|
||||
throw new Error(`服务器返回状态码 ${resp.responseCode}`);
|
||||
}
|
||||
const xml: string = resp.result as string;
|
||||
LogUtil.write(`HTTP REPORT(${compName}) 响应体 ${xml.length} 字符`);
|
||||
const originMatch = /https?:\/\/[^/]+/i.exec(href);
|
||||
const origin: string = originMatch !== null ? originMatch[0] : '';
|
||||
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 etag: string = DavClient.extractTag(block, 'getetag').replace(/"/g, '');
|
||||
const start: number = block.indexOf('BEGIN:VCALENDAR');
|
||||
const end: number = block.indexOf('END:VCALENDAR');
|
||||
if (start < 0 || end < 0) {
|
||||
continue;
|
||||
}
|
||||
const item = new RemoteItem();
|
||||
item.href = resHref.startsWith('http') ? resHref : origin + resHref;
|
||||
item.etag = etag;
|
||||
item.ics = block.substring(start, end + 'END:VCALENDAR'.length);
|
||||
items.push(item);
|
||||
}
|
||||
return items;
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** PUT 新建/更新远端事件,返回响应 ETag(可能为空) */
|
||||
static async putEvent(url: string, auth: string, ics: string): Promise<string> {
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const resp: http.HttpResponse = await httpRequest.request(url, {
|
||||
method: http.RequestMethod.PUT,
|
||||
header: {
|
||||
'Authorization': auth,
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'User-Agent': 'SyncCalendar/1.0'
|
||||
},
|
||||
extraData: ics,
|
||||
connectTimeout: 10000,
|
||||
readTimeout: 30000
|
||||
});
|
||||
console.info(`PUT ${url} 响应码: ${resp.responseCode}`);
|
||||
LogUtil.write(`HTTP PUT ${url} → ${resp.responseCode}`);
|
||||
if (resp.responseCode < 200 || resp.responseCode >= 300) {
|
||||
throw new Error(`推送失败,服务器返回 ${resp.responseCode}`);
|
||||
}
|
||||
const headers = resp.header as Record<string, string>;
|
||||
if (headers !== undefined && headers !== null) {
|
||||
const etag = headers['ETag'] ?? headers['etag'] ?? '';
|
||||
return typeof etag === 'string' ? etag.replace(/"/g, '') : '';
|
||||
}
|
||||
return '';
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE 远端事件 */
|
||||
static async deleteRemote(url: string, auth: string): Promise<void> {
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const resp: http.HttpResponse = await httpRequest.request(url, {
|
||||
method: http.RequestMethod.DELETE,
|
||||
header: {
|
||||
'Authorization': auth,
|
||||
'User-Agent': 'SyncCalendar/1.0'
|
||||
},
|
||||
connectTimeout: 10000,
|
||||
readTimeout: 30000
|
||||
});
|
||||
console.info(`DELETE ${url} 响应码: ${resp.responseCode}`);
|
||||
LogUtil.write(`HTTP DELETE ${url} → ${resp.responseCode}`);
|
||||
// 404 视为已删除,成功
|
||||
if ((resp.responseCode < 200 || resp.responseCode >= 300) && resp.responseCode !== 404) {
|
||||
throw new Error(`删除失败,服务器返回 ${resp.responseCode}`);
|
||||
}
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** 提取任意命名空间前缀标签的内容 */
|
||||
static extractTag(xml: string, tag: string): string {
|
||||
const pattern: string = '<([\\w-]+:)?' + tag + '(?:\\s[^>]*)?>([\\s\\S]*?)<\\/([\\w-]+:)?' + tag + '>';
|
||||
const regex: RegExp = new RegExp(pattern, 'i');
|
||||
const match = regex.exec(xml);
|
||||
return match !== null ? match[2].trim() : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
// entry/src/main/ets/common/EventDb.ets
|
||||
// 本地事件数据库(relationalStore):DAV 同步来的日程与本地新建日程统一存储
|
||||
import { relationalStore } from '@kit.ArkData';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
|
||||
/** 本地事件行 */
|
||||
export class LocalEvent {
|
||||
id: number = 0;
|
||||
uid: string = '';
|
||||
calKey: string = ''; // 'accId_idx' 或 'local'
|
||||
href: string = ''; // 所属日历本集合 URL(local 为空)
|
||||
remotePath: string = ''; // 集合内资源文件名,如 <uid>.ics(local 为空)
|
||||
title: string = '';
|
||||
description: string = '';
|
||||
location: string = '';
|
||||
startTime: number = 0; // 13 位毫秒;全天日程为当天 0 点
|
||||
endTime: number = 0; // 全天日程为排他结束日前一毫秒(沿用 iCal 约定减 1s 存储)
|
||||
isAllDay: boolean = false;
|
||||
etag: string = '';
|
||||
dirty: boolean = false; // 本地有修改,待推送
|
||||
deleted: boolean = false; // 本地已删除,待推送
|
||||
recurring: boolean = false; // 重复日程实例(暂不支持推送,避免破坏服务器序列)
|
||||
kind: string = 'event'; // 'event' 日程 | 'todo' 待办(VTODO,只读展示)
|
||||
completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED)
|
||||
rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生
|
||||
exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔
|
||||
reminder: number = 0; // 提醒提前分钟数(来自 VALARM,0 = 不提醒)
|
||||
}
|
||||
|
||||
/** 远端事件条目(REPORT 解析结果) */
|
||||
export class RemoteEvent {
|
||||
uid: string = '';
|
||||
etag: string = '';
|
||||
title: string = '';
|
||||
description: string = '';
|
||||
location: string = '';
|
||||
startTime: number = 0;
|
||||
endTime: number = 0;
|
||||
isAllDay: boolean = false;
|
||||
recurring: boolean = false; // 含 RRULE 或 RECURREIENCE-ID 的实例
|
||||
isTodo: boolean = false; // VTODO 待办
|
||||
completed: boolean = false; // VTODO STATUS:COMPLETED
|
||||
rrule: string = ''; // 原始 RRULE
|
||||
exdate: string = ''; // 原始 EXDATE(分号分隔)
|
||||
reminder: number = 0; // 提醒提前分钟数(VALARM)
|
||||
}
|
||||
|
||||
export class EventDb {
|
||||
private static db: relationalStore.RdbStore | null = null;
|
||||
|
||||
static async getDb(context: common.Context): Promise<relationalStore.RdbStore> {
|
||||
if (EventDb.db !== null) {
|
||||
return EventDb.db;
|
||||
}
|
||||
const config: relationalStore.StoreConfig = {
|
||||
name: 'sync_calendar.db',
|
||||
securityLevel: relationalStore.SecurityLevel.S1
|
||||
};
|
||||
const store = await relationalStore.getRdbStore(context, config);
|
||||
await store.executeSql(
|
||||
'CREATE TABLE IF NOT EXISTS events (' +
|
||||
'id INTEGER PRIMARY KEY AUTOINCREMENT, ' +
|
||||
'uid TEXT, cal_key TEXT, href TEXT, remote_path TEXT, ' +
|
||||
'title TEXT, description TEXT, location TEXT, ' +
|
||||
'start_time INTEGER, end_time INTEGER, is_all_day INTEGER, ' +
|
||||
'etag TEXT, dirty INTEGER, deleted INTEGER, recurring INTEGER)'
|
||||
);
|
||||
// 旧版本库补列(已存在会抛错,忽略)
|
||||
try {
|
||||
await store.executeSql('ALTER TABLE events ADD COLUMN recurring INTEGER DEFAULT 0');
|
||||
} catch (err) {
|
||||
// 列已存在
|
||||
}
|
||||
try {
|
||||
await store.executeSql("ALTER TABLE events ADD COLUMN kind TEXT DEFAULT 'event'");
|
||||
} catch (err) {
|
||||
// 列已存在
|
||||
}
|
||||
try {
|
||||
await store.executeSql('ALTER TABLE events ADD COLUMN completed INTEGER DEFAULT 0');
|
||||
} catch (err) {
|
||||
// 列已存在
|
||||
}
|
||||
try {
|
||||
await store.executeSql("ALTER TABLE events ADD COLUMN rrule TEXT DEFAULT ''");
|
||||
} catch (err) {
|
||||
// 列已存在
|
||||
}
|
||||
try {
|
||||
await store.executeSql("ALTER TABLE events ADD COLUMN exdate TEXT DEFAULT ''");
|
||||
} catch (err) {
|
||||
// 列已存在
|
||||
}
|
||||
try {
|
||||
await store.executeSql('ALTER TABLE events ADD COLUMN reminder INTEGER DEFAULT 0');
|
||||
} catch (err) {
|
||||
// 列已存在
|
||||
}
|
||||
EventDb.db = store;
|
||||
return store;
|
||||
}
|
||||
|
||||
private static fromRow(rs: relationalStore.ResultSet): LocalEvent {
|
||||
const e = new LocalEvent();
|
||||
e.id = rs.getLong(rs.getColumnIndex('id'));
|
||||
e.uid = rs.getString(rs.getColumnIndex('uid'));
|
||||
e.calKey = rs.getString(rs.getColumnIndex('cal_key'));
|
||||
e.href = rs.getString(rs.getColumnIndex('href'));
|
||||
e.remotePath = rs.getString(rs.getColumnIndex('remote_path'));
|
||||
e.title = rs.getString(rs.getColumnIndex('title'));
|
||||
e.description = rs.getString(rs.getColumnIndex('description'));
|
||||
e.location = rs.getString(rs.getColumnIndex('location'));
|
||||
e.startTime = rs.getLong(rs.getColumnIndex('start_time'));
|
||||
e.endTime = rs.getLong(rs.getColumnIndex('end_time'));
|
||||
e.isAllDay = rs.getLong(rs.getColumnIndex('is_all_day')) === 1;
|
||||
e.etag = rs.getString(rs.getColumnIndex('etag'));
|
||||
e.dirty = rs.getLong(rs.getColumnIndex('dirty')) === 1;
|
||||
e.deleted = rs.getLong(rs.getColumnIndex('deleted')) === 1;
|
||||
e.recurring = rs.getLong(rs.getColumnIndex('recurring')) === 1;
|
||||
const kind: string = rs.getString(rs.getColumnIndex('kind'));
|
||||
e.kind = kind === '' ? 'event' : kind;
|
||||
e.completed = rs.getLong(rs.getColumnIndex('completed')) === 1;
|
||||
e.rrule = rs.getString(rs.getColumnIndex('rrule'));
|
||||
e.exdate = rs.getString(rs.getColumnIndex('exdate'));
|
||||
e.reminder = rs.getLong(rs.getColumnIndex('reminder'));
|
||||
return e;
|
||||
}
|
||||
|
||||
private static toBucket(e: LocalEvent): relationalStore.ValuesBucket {
|
||||
const bucket: relationalStore.ValuesBucket = {
|
||||
'uid': e.uid,
|
||||
'cal_key': e.calKey,
|
||||
'href': e.href,
|
||||
'remote_path': e.remotePath,
|
||||
'title': e.title,
|
||||
'description': e.description,
|
||||
'location': e.location,
|
||||
'start_time': e.startTime,
|
||||
'end_time': e.endTime,
|
||||
'is_all_day': e.isAllDay ? 1 : 0,
|
||||
'etag': e.etag,
|
||||
'dirty': e.dirty ? 1 : 0,
|
||||
'deleted': e.deleted ? 1 : 0,
|
||||
'recurring': e.recurring ? 1 : 0,
|
||||
'kind': e.kind,
|
||||
'completed': e.completed ? 1 : 0,
|
||||
'rrule': e.rrule,
|
||||
'exdate': e.exdate,
|
||||
'reminder': e.reminder
|
||||
};
|
||||
return bucket;
|
||||
}
|
||||
|
||||
/** 新建本地事件 */
|
||||
static async insertLocal(context: common.Context, e: LocalEvent): Promise<number> {
|
||||
const store = await EventDb.getDb(context);
|
||||
e.dirty = true;
|
||||
const rowId = await store.insert('events', EventDb.toBucket(e));
|
||||
return rowId;
|
||||
}
|
||||
|
||||
/** 更新本地事件(置 dirty 待推送) */
|
||||
static async updateLocal(context: common.Context, e: LocalEvent): Promise<void> {
|
||||
const store = await EventDb.getDb(context);
|
||||
e.dirty = true;
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('id', e.id);
|
||||
await store.update(EventDb.toBucket(e), predicates);
|
||||
}
|
||||
|
||||
/** 标记删除(待推送 DELETE) */
|
||||
static async markDeleted(context: common.Context, id: number): Promise<void> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const bucket: relationalStore.ValuesBucket = { 'deleted': 1, 'dirty': 1 };
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('id', id);
|
||||
await store.update(bucket, predicates);
|
||||
}
|
||||
|
||||
/** 推送成功后清除 dirty(可回写 etag) */
|
||||
static async clearDirty(context: common.Context, id: number, etag: string): Promise<void> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const bucket: relationalStore.ValuesBucket = { 'dirty': 0, 'etag': etag };
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('id', id);
|
||||
await store.update(bucket, predicates);
|
||||
}
|
||||
|
||||
/** 推送删除成功后物理删除 */
|
||||
static async purge(context: common.Context, id: number): Promise<void> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('id', id);
|
||||
await store.delete(predicates);
|
||||
}
|
||||
|
||||
/** 所有待推送事件 */
|
||||
static async getDirty(context: common.Context): Promise<LocalEvent[]> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('dirty', 1);
|
||||
const rs = await store.query(predicates);
|
||||
const list: LocalEvent[] = [];
|
||||
try {
|
||||
while (rs.goToNextRow()) {
|
||||
list.push(EventDb.fromRow(rs));
|
||||
}
|
||||
} finally {
|
||||
rs.close();
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 查询时间区间内未删除的日程(排除待办,待办单独展示) */
|
||||
static async queryRange(context: common.Context, start: number, end: number): Promise<LocalEvent[]> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('deleted', 0).and().equalTo('kind', 'event')
|
||||
.and().lessThanOrEqualTo('start_time', end)
|
||||
.and().greaterThanOrEqualTo('end_time', start);
|
||||
const rs = await store.query(predicates);
|
||||
const list: LocalEvent[] = [];
|
||||
try {
|
||||
while (rs.goToNextRow()) {
|
||||
list.push(EventDb.fromRow(rs));
|
||||
}
|
||||
} finally {
|
||||
rs.close();
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 单个事件 */
|
||||
static async getById(context: common.Context, id: number): Promise<LocalEvent | null> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('id', id);
|
||||
const rs = await store.query(predicates);
|
||||
let result: LocalEvent | null = null;
|
||||
try {
|
||||
if (rs.goToNextRow()) {
|
||||
result = EventDb.fromRow(rs);
|
||||
}
|
||||
} finally {
|
||||
rs.close();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 全部未删除的待办(VTODO),未完成在前、按截止时间升序 */
|
||||
static async queryTodos(context: common.Context): Promise<LocalEvent[]> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('deleted', 0).and().equalTo('kind', 'todo');
|
||||
const rs = await store.query(predicates);
|
||||
const list: LocalEvent[] = [];
|
||||
try {
|
||||
while (rs.goToNextRow()) {
|
||||
list.push(EventDb.fromRow(rs));
|
||||
}
|
||||
} finally {
|
||||
rs.close();
|
||||
}
|
||||
list.sort((a: LocalEvent, b: LocalEvent): number => {
|
||||
if (a.completed !== b.completed) {
|
||||
return a.completed ? 1 : -1;
|
||||
}
|
||||
return a.startTime - b.startTime;
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 全部重复主事件(有 RRULE 且开始时间在 before 之前,用于跨窗口展开) */
|
||||
static async queryRecurringMasters(context: common.Context, before: number): Promise<LocalEvent[]> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('deleted', 0).and().equalTo('kind', 'event')
|
||||
.and().notEqualTo('rrule', '').and().lessThanOrEqualTo('start_time', before);
|
||||
const rs = await store.query(predicates);
|
||||
const list: LocalEvent[] = [];
|
||||
try {
|
||||
while (rs.goToNextRow()) {
|
||||
list.push(EventDb.fromRow(rs));
|
||||
}
|
||||
} finally {
|
||||
rs.close();
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 未来 7 天内需要提醒的日程(reminder > 0),按开始时间升序 */
|
||||
static async queryRemindable(context: common.Context, from: number, to: number): Promise<LocalEvent[]> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('deleted', 0).and().equalTo('kind', 'event')
|
||||
.and().greaterThan('reminder', 0)
|
||||
.and().greaterThanOrEqualTo('start_time', from)
|
||||
.and().lessThanOrEqualTo('start_time', to)
|
||||
.orderByAsc('start_time').limitAs(50);
|
||||
const rs = await store.query(predicates);
|
||||
const list: LocalEvent[] = [];
|
||||
try {
|
||||
while (rs.goToNextRow()) {
|
||||
list.push(EventDb.fromRow(rs));
|
||||
}
|
||||
} finally {
|
||||
rs.close();
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用远端数据刷新某个日历本(增量):
|
||||
* - etag 未变的跳过;变化的更新;远端没有的本地图删掉(排除本地待推送的新事件)
|
||||
* - 返回统计描述:"新增X 更新Y 删除Z 不变W"
|
||||
*/
|
||||
static async applyRemote(context: common.Context, calKey: string, href: string,
|
||||
remote: RemoteEvent[], isTodo: boolean): Promise<string> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const kind: string = isTodo ? 'todo' : 'event';
|
||||
let added: number = 0;
|
||||
let updated: number = 0;
|
||||
let removed: number = 0;
|
||||
let unchanged: number = 0;
|
||||
// 读取该日历本当前所有行(仅同类型)
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('cal_key', calKey).and().equalTo('kind', kind);
|
||||
const rs = await store.query(predicates);
|
||||
const existing: LocalEvent[] = [];
|
||||
try {
|
||||
while (rs.goToNextRow()) {
|
||||
existing.push(EventDb.fromRow(rs));
|
||||
}
|
||||
} finally {
|
||||
rs.close();
|
||||
}
|
||||
|
||||
const remoteKeys: string[] = [];
|
||||
for (const r of remote) {
|
||||
// 重复日程实例共享 UID,唯一标识 = uid + 开始时间
|
||||
const key: string = `${r.uid}_${r.startTime}`;
|
||||
remoteKeys.push(key);
|
||||
const found = existing.find((x: LocalEvent): boolean =>
|
||||
!x.dirty && x.uid === r.uid && x.startTime === r.startTime);
|
||||
if (found === undefined) {
|
||||
// 新增
|
||||
const e = new LocalEvent();
|
||||
e.uid = r.uid;
|
||||
e.calKey = calKey;
|
||||
e.href = href;
|
||||
e.remotePath = encodeURIComponent(r.uid) + '.ics';
|
||||
e.title = r.title;
|
||||
e.description = r.description;
|
||||
e.location = r.location;
|
||||
e.startTime = r.startTime;
|
||||
e.endTime = r.endTime;
|
||||
e.isAllDay = r.isAllDay;
|
||||
e.etag = r.etag;
|
||||
e.dirty = false;
|
||||
e.deleted = false;
|
||||
e.recurring = r.recurring;
|
||||
e.kind = kind;
|
||||
e.completed = r.completed;
|
||||
e.rrule = r.rrule;
|
||||
e.exdate = r.exdate;
|
||||
e.reminder = r.reminder;
|
||||
await store.insert('events', EventDb.toBucket(e));
|
||||
added++;
|
||||
} else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate
|
||||
|| found.reminder !== r.reminder) {
|
||||
// 更新(rrule/exdate 变化也更新,兼容老数据回填)
|
||||
found.title = r.title;
|
||||
found.description = r.description;
|
||||
found.location = r.location;
|
||||
found.startTime = r.startTime;
|
||||
found.endTime = r.endTime;
|
||||
found.isAllDay = r.isAllDay;
|
||||
found.etag = r.etag;
|
||||
found.dirty = false;
|
||||
found.deleted = false;
|
||||
found.recurring = r.recurring;
|
||||
found.kind = kind;
|
||||
found.completed = r.completed;
|
||||
found.rrule = r.rrule;
|
||||
found.exdate = r.exdate;
|
||||
found.reminder = r.reminder;
|
||||
const up = new relationalStore.RdbPredicates('events');
|
||||
up.equalTo('id', found.id);
|
||||
await store.update(EventDb.toBucket(found), up);
|
||||
updated++;
|
||||
} else {
|
||||
unchanged++;
|
||||
}
|
||||
}
|
||||
// 删除远端已不存在的(排除本地修改未推送的)
|
||||
for (const local of existing) {
|
||||
if (local.dirty) {
|
||||
continue;
|
||||
}
|
||||
const localKey: string = `${local.uid}_${local.startTime}`;
|
||||
if (!remoteKeys.includes(localKey)) {
|
||||
const del = new relationalStore.RdbPredicates('events');
|
||||
del.equalTo('id', local.id);
|
||||
await store.delete(del);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return `新增${added} 更新${updated} 删除${removed} 不变${unchanged}`;
|
||||
}
|
||||
|
||||
/** 删除某账号全部本地事件(删除账号时调用) */
|
||||
static async deleteAccountEvents(context: common.Context, accId: string): Promise<void> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.like(`cal_key`, `${accId}%`);
|
||||
await store.delete(predicates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新勾选日历本后清理失效数据:
|
||||
* 删除该账号下 calKey 不在有效列表中的本地日程/待办(calKey = accId_序号,重选后序号会变)
|
||||
*/
|
||||
static async pruneAccountEvents(context: common.Context, accId: string,
|
||||
validCalKeys: string[]): Promise<void> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.like('cal_key', `${accId}_%`);
|
||||
if (validCalKeys.length > 0) {
|
||||
predicates.and().notIn('cal_key', validCalKeys);
|
||||
}
|
||||
await store.delete(predicates);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// entry/src/main/ets/common/IcsUtil.ets
|
||||
// iCalendar 解析与生成
|
||||
import { LocalEvent, RemoteEvent } from './EventDb';
|
||||
|
||||
/** 解析出的远端日程(内部中间结构) */
|
||||
class ParsedEvent {
|
||||
uid: string = '';
|
||||
title: string = '';
|
||||
description: string = '';
|
||||
location: string = '';
|
||||
startTime: number = 0;
|
||||
endTime: number = 0;
|
||||
isAllDay: boolean = false;
|
||||
recurring: boolean = false;
|
||||
duration: number = 0; // DURATION 属性(毫秒),无 DTEND 时使用
|
||||
inAlarm: boolean = false; // 是否处于 VALARM 子组件内(内部属性不参与解析)
|
||||
rrule: string = ''; // 原始 RRULE 值
|
||||
exdates: string[] = []; // 原始 EXDATE 值列表
|
||||
reminder: number = 0; // 提醒提前分钟数(来自 VALARM TRIGGER,0 = 不提醒)
|
||||
}
|
||||
|
||||
/** iCal 时间解析结果 */
|
||||
export class IcsTime {
|
||||
time: number = 0;
|
||||
allDay: boolean = false;
|
||||
}
|
||||
|
||||
export class IcsUtil {
|
||||
/** 解析 VCALENDAR 文本为事件列表 */
|
||||
static parse(ics: string): RemoteEvent[] {
|
||||
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
|
||||
const lines: string[] = unfolded.split(/\r?\n/);
|
||||
const parsed: ParsedEvent[] = [];
|
||||
let current: ParsedEvent | null = null;
|
||||
for (const line of lines) {
|
||||
const upper: string = line.toUpperCase();
|
||||
if (upper.startsWith('BEGIN:VEVENT')) {
|
||||
current = new ParsedEvent();
|
||||
continue;
|
||||
}
|
||||
if (upper.startsWith('END:VEVENT')) {
|
||||
if (current !== null && current.startTime > 0) {
|
||||
// 无 DTEND 时用 DURATION;都没有则视为零长事件(不再丢弃整条)
|
||||
if (current.endTime < current.startTime) {
|
||||
current.endTime = current.duration > 0
|
||||
? current.startTime + current.duration
|
||||
: current.startTime;
|
||||
}
|
||||
parsed.push(current);
|
||||
}
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
if (current === null) {
|
||||
continue;
|
||||
}
|
||||
// 跳过 VALARM 闹钟子组件,避免其 DESCRIPTION 等属性污染事件
|
||||
if (upper.startsWith('BEGIN:VALARM')) {
|
||||
current.inAlarm = true;
|
||||
continue;
|
||||
}
|
||||
if (upper.startsWith('END:VALARM')) {
|
||||
current.inAlarm = false;
|
||||
continue;
|
||||
}
|
||||
if (current.inAlarm) {
|
||||
// VALARM 内部:捕获 TRIGGER 提醒提前量(如 -PT10M = 提前 10 分钟)
|
||||
const ac: number = line.indexOf(':');
|
||||
if (ac > 0) {
|
||||
const ap: string = line.substring(0, ac);
|
||||
const av: string = line.substring(ac + 1).trim();
|
||||
const an: string = ap.split(';')[0].toUpperCase();
|
||||
if (an === 'TRIGGER') {
|
||||
current.reminder = IcsUtil.triggerMinutes(av, ap);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const colon: number = line.indexOf(':');
|
||||
if (colon <= 0) {
|
||||
continue;
|
||||
}
|
||||
const propPart: string = line.substring(0, colon);
|
||||
const value: string = line.substring(colon + 1).trim();
|
||||
const propName: string = propPart.split(';')[0].toUpperCase();
|
||||
if (propName === 'UID') {
|
||||
current.uid = value;
|
||||
} else if (propName === 'SUMMARY') {
|
||||
current.title = IcsUtil.unescape(value);
|
||||
} else if (propName === 'DESCRIPTION') {
|
||||
current.description = IcsUtil.unescape(value);
|
||||
} else if (propName === 'LOCATION') {
|
||||
current.location = IcsUtil.unescape(value);
|
||||
} else if (propName === 'RRULE' || propName === 'RECURREIENCE-ID') {
|
||||
current.recurring = true;
|
||||
if (propName === 'RRULE') {
|
||||
current.rrule = value;
|
||||
}
|
||||
} else if (propName === 'EXDATE') {
|
||||
const exValues: string[] = value.split(',');
|
||||
for (const ex of exValues) {
|
||||
if (ex.trim() !== '') {
|
||||
current.exdates.push(ex.trim());
|
||||
}
|
||||
}
|
||||
} else if (propName === 'DURATION') {
|
||||
current.duration = IcsUtil.parseDurationMs(value);
|
||||
} else if (propName === 'DTSTART') {
|
||||
const isDateOnly: boolean = propPart.toUpperCase().includes('VALUE=DATE');
|
||||
const t = IcsUtil.parseTime(value, isDateOnly);
|
||||
if (t !== null) {
|
||||
current.startTime = t.time;
|
||||
current.isAllDay = t.allDay;
|
||||
}
|
||||
} else if (propName === 'DTEND') {
|
||||
const isDateOnly: boolean = propPart.toUpperCase().includes('VALUE=DATE');
|
||||
const t = IcsUtil.parseTime(value, isDateOnly);
|
||||
if (t !== null) {
|
||||
// 全天日程 DTEND 为排他日期,减 1 秒存储为闭区间结束
|
||||
current.endTime = t.allDay ? t.time - 1000 : t.time;
|
||||
}
|
||||
}
|
||||
}
|
||||
return parsed.map((p: ParsedEvent): RemoteEvent => {
|
||||
const r = new RemoteEvent();
|
||||
r.uid = p.uid;
|
||||
r.title = p.title;
|
||||
r.description = p.description;
|
||||
r.location = p.location;
|
||||
r.startTime = p.startTime;
|
||||
r.endTime = p.endTime;
|
||||
r.isAllDay = p.isAllDay;
|
||||
r.recurring = p.recurring;
|
||||
r.rrule = p.rrule;
|
||||
r.exdate = p.exdates.join(';');
|
||||
r.reminder = p.reminder;
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
/** VALARM TRIGGER → 提前分钟数(负时长=提前);绝对时间触发暂不支持 */
|
||||
private static triggerMinutes(value: string, propPart: string): number {
|
||||
if (propPart.toUpperCase().includes('VALUE=DATE-TIME')) {
|
||||
return 0;
|
||||
}
|
||||
const dur: number = IcsUtil.parseDurationMs(value);
|
||||
if (dur < 0) {
|
||||
return Math.min(1440, Math.round(-dur / 60000));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** 解析 VCALENDAR 中的 VTODO 待办列表(待办为只读展示,不参与推送) */
|
||||
static parseTodos(ics: string): RemoteEvent[] {
|
||||
const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
|
||||
const lines: string[] = unfolded.split(/\r?\n/);
|
||||
const parsed: RemoteEvent[] = [];
|
||||
let current: RemoteEvent | null = null;
|
||||
for (const line of lines) {
|
||||
const upper: string = line.toUpperCase();
|
||||
if (upper.startsWith('BEGIN:VTODO')) {
|
||||
current = new RemoteEvent();
|
||||
current.isTodo = true;
|
||||
continue;
|
||||
}
|
||||
if (upper.startsWith('END:VTODO')) {
|
||||
if (current !== null && current.uid !== '') {
|
||||
parsed.push(current);
|
||||
}
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
if (current === null) {
|
||||
continue;
|
||||
}
|
||||
const colon: number = line.indexOf(':');
|
||||
if (colon <= 0) {
|
||||
continue;
|
||||
}
|
||||
const propPart: string = line.substring(0, colon);
|
||||
const value: string = line.substring(colon + 1).trim();
|
||||
const propName: string = propPart.split(';')[0].toUpperCase();
|
||||
if (propName === 'UID') {
|
||||
current.uid = value;
|
||||
} else if (propName === 'SUMMARY') {
|
||||
current.title = IcsUtil.unescape(value);
|
||||
} else if (propName === 'DESCRIPTION') {
|
||||
current.description = IcsUtil.unescape(value);
|
||||
} else if (propName === 'LOCATION') {
|
||||
current.location = IcsUtil.unescape(value);
|
||||
} else if (propName === 'RRULE') {
|
||||
current.recurring = true;
|
||||
} else if (propName === 'DUE' || propName === 'DTSTART') {
|
||||
const isDateOnly: boolean = propPart.toUpperCase().includes('VALUE=DATE');
|
||||
const t = IcsUtil.parseTime(value, isDateOnly);
|
||||
if (t !== null) {
|
||||
current.startTime = t.time;
|
||||
// 全天待办截止日按当天结束存储;普通待办开始=截止
|
||||
current.endTime = t.allDay ? t.time + 86399000 : t.time;
|
||||
current.isAllDay = t.allDay;
|
||||
}
|
||||
} else if (propName === 'STATUS') {
|
||||
if (value.toUpperCase() === 'COMPLETED') {
|
||||
current.completed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** 由本地事件构建 VCALENDAR 文本(时间统一转 UTC,保证服务器端时区正确) */
|
||||
static build(e: LocalEvent): string {
|
||||
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
|
||||
const fmtUtc = (ms: number): string => {
|
||||
const d = new Date(ms);
|
||||
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
|
||||
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
|
||||
};
|
||||
const fmtDate = (ms: number): string => {
|
||||
const d = new Date(ms);
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
|
||||
};
|
||||
let dtstart: string;
|
||||
let dtend: string;
|
||||
if (e.isAllDay) {
|
||||
dtstart = 'DTSTART;VALUE=DATE:' + fmtDate(e.startTime);
|
||||
// 存储的 endTime 为排他日前一毫秒,+1s 得到排他日期
|
||||
dtend = 'DTEND;VALUE=DATE:' + fmtDate(e.endTime + 1000);
|
||||
} else {
|
||||
dtstart = 'DTSTART:' + fmtUtc(e.startTime);
|
||||
dtend = 'DTEND:' + fmtUtc(e.endTime);
|
||||
}
|
||||
const now: string = fmtUtc(Date.now());
|
||||
return 'BEGIN:VCALENDAR\r\n' +
|
||||
'VERSION:2.0\r\n' +
|
||||
'PRODID:-//SyncCalendar//Calendar//CN\r\n' +
|
||||
'CALSCALE:GREGORIAN\r\n' +
|
||||
'BEGIN:VEVENT\r\n' +
|
||||
`UID:${e.uid}\r\n` +
|
||||
`DTSTAMP:${now}\r\n` +
|
||||
`${dtstart}\r\n` +
|
||||
`${dtend}\r\n` +
|
||||
`SUMMARY:${IcsUtil.escape(e.title)}\r\n` +
|
||||
(e.location !== '' ? `LOCATION:${IcsUtil.escape(e.location)}\r\n` : '') +
|
||||
(e.description !== '' ? `DESCRIPTION:${IcsUtil.escape(e.description)}\r\n` : '') +
|
||||
'END:VEVENT\r\n' +
|
||||
'END:VCALENDAR\r\n';
|
||||
}
|
||||
|
||||
static escape(s: string): string {
|
||||
return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
|
||||
}
|
||||
|
||||
private static unescape(s: string): string {
|
||||
return s.replace(/\\n/gi, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
/** 解析 iCal DURATION(ISO 8601 时长,如 PT1H30M / P2D / P1W),返回毫秒 */
|
||||
private static parseDurationMs(value: string): number {
|
||||
const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/
|
||||
.exec(value.trim().toUpperCase());
|
||||
if (m === null) {
|
||||
return 0;
|
||||
}
|
||||
const sign: number = m[1] === '-' ? -1 : 1;
|
||||
const w: number = Number(m[2] ?? '0');
|
||||
const d: number = Number(m[3] ?? '0');
|
||||
const h: number = Number(m[4] ?? '0');
|
||||
const mi: number = Number(m[5] ?? '0');
|
||||
const s: number = Number(m[6] ?? '0');
|
||||
const total: number = (((w * 7 + d) * 24 + h) * 60 + mi) * 60 + s;
|
||||
return sign * total * 1000;
|
||||
}
|
||||
|
||||
/** 解析 iCal 时间值,返回 13 位毫秒时间戳(供 EXDATE 解析等复用) */
|
||||
static parseTime(value: string, dateOnly: boolean): IcsTime | null {
|
||||
const v: string = value.trim();
|
||||
if (/^\d{8}$/.test(v) || dateOnly) {
|
||||
const m = /^(\d{4})(\d{2})(\d{2})/.exec(v);
|
||||
if (m === null) {
|
||||
return null;
|
||||
}
|
||||
const t = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime();
|
||||
const result = new IcsTime();
|
||||
result.time = t;
|
||||
result.allDay = true;
|
||||
return result;
|
||||
}
|
||||
const m2 = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/.exec(v);
|
||||
if (m2 === null) {
|
||||
return null;
|
||||
}
|
||||
const isUtc: boolean = m2[7] === 'Z';
|
||||
let t2: number;
|
||||
if (isUtc) {
|
||||
t2 = Date.UTC(Number(m2[1]), Number(m2[2]) - 1, Number(m2[3]),
|
||||
Number(m2[4]), Number(m2[5]), Number(m2[6]));
|
||||
} else {
|
||||
// TZID 时区未做完整换算,按设备本地时区近似
|
||||
t2 = new Date(Number(m2[1]), Number(m2[2]) - 1, Number(m2[3]),
|
||||
Number(m2[4]), Number(m2[5]), Number(m2[6])).getTime();
|
||||
}
|
||||
const result2 = new IcsTime();
|
||||
result2.time = t2;
|
||||
result2.allDay = false;
|
||||
return result2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// entry/src/main/ets/common/LogUtil.ets
|
||||
// 日志功能已按需求移除:保留空实现以兼容既有调用点(不写文件、不再缓冲)
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
export class LogUtil {
|
||||
static logFilePath(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 空实现(日志功能已移除) */
|
||||
static init(context: common.Context): void {
|
||||
}
|
||||
|
||||
/** 仅输出到 console(hilog),不再写文件 */
|
||||
static write(msg: string): void {
|
||||
console.info(`SyncLog ${msg}`);
|
||||
}
|
||||
|
||||
static readAll(context: common.Context): string {
|
||||
return '(日志功能已移除)';
|
||||
}
|
||||
|
||||
static clear(context: common.Context): void {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// entry/src/main/ets/common/LunarUtil.ets
|
||||
// 公历 → 农历(1901~2099),经典压缩表算法
|
||||
export class LunarDate {
|
||||
year: number = 0;
|
||||
month: number = 0; // 1~12
|
||||
day: number = 0; // 1~30
|
||||
isLeap: boolean = false;
|
||||
}
|
||||
|
||||
export class LunarUtil {
|
||||
// 农历压缩数据表:每年一个十六进制值(1900~2100)
|
||||
private static readonly LUNAR_INFO: number[] = [
|
||||
0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2, // 1900-1909
|
||||
0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977, // 1910-1919
|
||||
0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970, // 1920-1929
|
||||
0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950, // 1930-1939
|
||||
0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557, // 1940-1949
|
||||
0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0, // 1950-1959
|
||||
0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0, // 1960-1969
|
||||
0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6, // 1970-1979
|
||||
0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570, // 1980-1989
|
||||
0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x05ac0, 0x0ab60, 0x096d5, 0x092e0, // 1990-1999
|
||||
0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5, // 2000-2009
|
||||
0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930, // 2010-2019
|
||||
0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530, // 2020-2029
|
||||
0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45, // 2030-2039
|
||||
0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0, // 2040-2049
|
||||
0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06b20, 0x1a6c4, 0x0aae0, // 2050-2059
|
||||
0x0a2e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4, // 2060-2069
|
||||
0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0, // 2070-2079
|
||||
0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160, // 2080-2089
|
||||
0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a2d0, 0x0d150, 0x0f252, // 2090-2099
|
||||
0x0d520 // 2100
|
||||
];
|
||||
|
||||
private static readonly MONTH_CN: string[] =
|
||||
['正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊'];
|
||||
private static readonly DIGIT_CN: string[] =
|
||||
['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
|
||||
|
||||
/** 农历闰月是哪个月(0 表示无闰月) */
|
||||
private static leapMonth(y: number): number {
|
||||
return LunarUtil.LUNAR_INFO[y - 1900] & 0xf;
|
||||
}
|
||||
|
||||
/** 农历闰月天数 */
|
||||
private static leapDays(y: number): number {
|
||||
if (LunarUtil.leapMonth(y) === 0) {
|
||||
return 0;
|
||||
}
|
||||
return (LunarUtil.LUNAR_INFO[y - 1900] & 0x10000) !== 0 ? 30 : 29;
|
||||
}
|
||||
|
||||
/** 农历 y 年 m 月(非闰月)天数 */
|
||||
private static monthDays(y: number, m: number): number {
|
||||
return (LunarUtil.LUNAR_INFO[y - 1900] & (0x10000 >> m)) !== 0 ? 30 : 29;
|
||||
}
|
||||
|
||||
/** 农历 y 年总天数 */
|
||||
private static yearDays(y: number): number {
|
||||
let sum: number = 348;
|
||||
for (let i: number = 0x8000; i > 0x8; i >>= 1) {
|
||||
sum += (LunarUtil.LUNAR_INFO[y - 1900] & i) !== 0 ? 1 : 0;
|
||||
}
|
||||
return sum + LunarUtil.leapDays(y);
|
||||
}
|
||||
|
||||
/** 公历毫秒 → 农历日期(超出 1901~2099 返回 null) */
|
||||
static lunar(ms: number): LunarDate | null {
|
||||
const d = new Date(ms);
|
||||
const year: number = d.getFullYear();
|
||||
const month: number = d.getMonth() + 1;
|
||||
const day: number = d.getDate();
|
||||
if (year < 1901 || year > 2099) {
|
||||
return null;
|
||||
}
|
||||
let offset: number =
|
||||
Math.floor((Date.UTC(year, month - 1, day) - Date.UTC(1900, 0, 31)) / 86400000);
|
||||
let i: number = 1900;
|
||||
let temp: number = 0;
|
||||
for (i = 1900; i < 2101 && offset > 0; i++) {
|
||||
temp = LunarUtil.yearDays(i);
|
||||
offset -= temp;
|
||||
}
|
||||
if (offset < 0) {
|
||||
offset += temp;
|
||||
i--;
|
||||
}
|
||||
const leap: number = LunarUtil.leapMonth(i);
|
||||
let isLeap: boolean = false;
|
||||
let m: number = 1;
|
||||
for (m = 1; m < 13 && offset > 0; m++) {
|
||||
if (leap > 0 && m === leap + 1 && isLeap === false) {
|
||||
--m;
|
||||
isLeap = true;
|
||||
temp = LunarUtil.leapDays(i);
|
||||
} else {
|
||||
temp = LunarUtil.monthDays(i, m);
|
||||
}
|
||||
if (isLeap === true && m === leap + 1) {
|
||||
isLeap = false;
|
||||
}
|
||||
offset -= temp;
|
||||
}
|
||||
if (offset === 0 && leap > 0 && m === leap + 1) {
|
||||
if (isLeap) {
|
||||
isLeap = false;
|
||||
} else {
|
||||
isLeap = true;
|
||||
--m;
|
||||
}
|
||||
}
|
||||
if (offset < 0) {
|
||||
offset += temp;
|
||||
--m;
|
||||
}
|
||||
const result = new LunarDate();
|
||||
result.year = i;
|
||||
result.month = m;
|
||||
result.day = offset + 1;
|
||||
result.isLeap = isLeap;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 农历日名称:初一~三十 */
|
||||
static dayName(day: number): string {
|
||||
const dg: string[] = LunarUtil.DIGIT_CN;
|
||||
if (day === 10) {
|
||||
return '初十';
|
||||
}
|
||||
if (day === 20) {
|
||||
return '二十';
|
||||
}
|
||||
if (day === 30) {
|
||||
return '三十';
|
||||
}
|
||||
if (day < 10) {
|
||||
return '初' + dg[day - 1];
|
||||
}
|
||||
if (day < 20) {
|
||||
return '十' + dg[day - 11];
|
||||
}
|
||||
return '廿' + dg[day - 21];
|
||||
}
|
||||
|
||||
/** 农历月名称:正月/二月/…/冬月/腊月,闰月带"闰" */
|
||||
static monthName(month: number, isLeap: boolean): string {
|
||||
const base: string = (month >= 1 && month <= 12) ? LunarUtil.MONTH_CN[month - 1] : '';
|
||||
return (isLeap ? '闰' : '') + base + '月';
|
||||
}
|
||||
|
||||
/**
|
||||
* 日历格子上显示的农历文本:
|
||||
* 初一时显示月名(如"九月"),其余显示日名(如"初五"、"十五")
|
||||
*/
|
||||
static lunarDayText(ms: number): string {
|
||||
const info = LunarUtil.lunar(ms);
|
||||
if (info === null) {
|
||||
return '';
|
||||
}
|
||||
if (info.day === 1) {
|
||||
return LunarUtil.monthName(info.month, info.isLeap);
|
||||
}
|
||||
return LunarUtil.dayName(info.day);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// entry/src/main/ets/common/ReminderService.ets
|
||||
// 日程提醒:基于后台代理提醒(reminderAgentManager),
|
||||
// 在日程开始前 N 分钟(来自服务器 VALARM TRIGGER)发系统通知,可右滑删除
|
||||
import { reminderAgentManager } from '@kit.BackgroundTasksKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { EventDb, LocalEvent } from './EventDb';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
export class ReminderService {
|
||||
private static readonly MAX_REMINDERS: number = 30; // 系统对单应用代理提醒数量有限制
|
||||
private static readonly WINDOW: number = 7 * 86400000; // 只排未来 7 天
|
||||
|
||||
/** 全量重建提醒:先取消本应用全部代理提醒,再按当前数据重新发布 */
|
||||
static async refreshReminders(context: common.Context): Promise<void> {
|
||||
try {
|
||||
await reminderAgentManager.cancelAllReminders();
|
||||
const now: number = Date.now();
|
||||
const rows: LocalEvent[] =
|
||||
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
|
||||
let published: number = 0;
|
||||
for (const e of rows) {
|
||||
if (published >= ReminderService.MAX_REMINDERS) {
|
||||
break;
|
||||
}
|
||||
const remindAt: number = e.startTime - e.reminder * 60000;
|
||||
if (remindAt <= now) {
|
||||
continue; // 提醒时间已过
|
||||
}
|
||||
const d = new Date(remindAt);
|
||||
const startTimeText: string = ReminderService.fmt(e.startTime);
|
||||
const request: reminderAgentManager.ReminderRequestCalendar = {
|
||||
reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,
|
||||
dateTime: {
|
||||
year: d.getFullYear(),
|
||||
month: d.getMonth() + 1,
|
||||
day: d.getDate(),
|
||||
hour: d.getHours(),
|
||||
minute: d.getMinutes(),
|
||||
second: 0
|
||||
},
|
||||
title: '日程提醒',
|
||||
content: `「${e.title}」将于 ${startTimeText} 开始(提前 ${e.reminder} 分钟)`,
|
||||
notificationId: e.id % 100000,
|
||||
expiredContent: '该日程已开始',
|
||||
ringDuration: 5,
|
||||
snoozeTimes: 0,
|
||||
timeInterval: 0
|
||||
};
|
||||
await reminderAgentManager.publishReminder(request);
|
||||
published++;
|
||||
}
|
||||
LogUtil.write(`提醒刷新完成:发布 ${published} 个提醒(窗口 7 天)`);
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`提醒刷新失败: ${e.code} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private static fmt(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const p = (n: number): string => n < 10 ? '0' + n : String(n);
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// entry/src/main/ets/common/RruleUtil.ets
|
||||
// RRULE 展开:支持 DAILY / WEEKLY / MONTHLY / YEARLY
|
||||
// 支持 INTERVAL、COUNT、UNTIL、BYDAY(周重复,如 MO,WE,FR)、BYMONTHDAY(月重复)
|
||||
export const DAY_MS: number = 86400000;
|
||||
|
||||
export class RruleUtil {
|
||||
/**
|
||||
* 展开重复规则,返回窗口内的发生时刻(毫秒时间戳)。
|
||||
* @param rrule 原始 RRULE 值(如 FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,TH)
|
||||
* @param dtstart 首次发生时间(毫秒)
|
||||
* @param windowStart 窗口起点(毫秒)
|
||||
* @param windowEnd 窗口终点(毫秒)
|
||||
* @param exdates EXDATE 排除时刻列表(毫秒),按整天或精确值匹配
|
||||
* @param maxCount 结果数量上限(防止超大结果)
|
||||
*/
|
||||
static expand(rrule: string, dtstart: number, windowStart: number, windowEnd: number,
|
||||
exdates: number[], maxCount: number): number[] {
|
||||
const result: number[] = [];
|
||||
if (rrule.trim() === '') {
|
||||
return result;
|
||||
}
|
||||
let freq: string = '';
|
||||
let interval: number = 1;
|
||||
let count: number = -1;
|
||||
let until: number = 0;
|
||||
let byday: string[] = [];
|
||||
let bymonthday: number[] = [];
|
||||
const parts: string[] = rrule.split(';');
|
||||
for (const part of parts) {
|
||||
const idx: number = part.indexOf('=');
|
||||
if (idx <= 0) {
|
||||
continue;
|
||||
}
|
||||
const key: string = part.substring(0, idx).trim().toUpperCase();
|
||||
const value: string = part.substring(idx + 1).trim();
|
||||
if (key === 'FREQ') {
|
||||
freq = value.toUpperCase();
|
||||
} else if (key === 'INTERVAL') {
|
||||
interval = Math.max(1, Number(value));
|
||||
} else if (key === 'COUNT') {
|
||||
count = Number(value);
|
||||
} else if (key === 'UNTIL') {
|
||||
until = RruleUtil.parseUntil(value);
|
||||
} else if (key === 'BYDAY') {
|
||||
byday = value.split(',').map((s: string): string => s.trim().toUpperCase());
|
||||
} else if (key === 'BYMONTHDAY') {
|
||||
bymonthday = value.split(',').map((s: string): number => Number(s.trim()));
|
||||
}
|
||||
}
|
||||
if (freq === '' || Number.isNaN(interval) || interval < 1) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const base = new Date(dtstart);
|
||||
const baseZero = new Date(dtstart);
|
||||
baseZero.setHours(0, 0, 0, 0);
|
||||
const baseZeroMs: number = baseZero.getTime();
|
||||
const timeOfDay: number = dtstart - baseZeroMs;
|
||||
let generated: number = 0;
|
||||
|
||||
// 接受一个发生:处理 COUNT/UNTIL/EXDATE/窗口过滤
|
||||
const accept = (occ: number): boolean => {
|
||||
if (count > 0 && generated >= count) {
|
||||
return false;
|
||||
}
|
||||
if (until > 0 && occ > until) {
|
||||
return false;
|
||||
}
|
||||
generated++;
|
||||
if (occ >= dtstart && occ <= windowEnd + DAY_MS - 1000 && occ >= windowStart - 40 * DAY_MS) {
|
||||
const occDay: number = RruleUtil.dayFloor(occ);
|
||||
let excluded: boolean = false;
|
||||
for (const ex of exdates) {
|
||||
if (ex === occ || RruleUtil.dayFloor(ex) === occDay) {
|
||||
excluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!excluded && result.length < maxCount) {
|
||||
result.push(occ);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
if (freq === 'DAILY') {
|
||||
let occ: number = dtstart;
|
||||
let guard: number = 0;
|
||||
while (occ <= windowEnd + DAY_MS && guard < 5000) {
|
||||
guard++;
|
||||
if (!accept(occ)) {
|
||||
break;
|
||||
}
|
||||
const d = new Date(occ);
|
||||
d.setDate(d.getDate() + interval);
|
||||
occ = d.getTime();
|
||||
}
|
||||
} else if (freq === 'WEEKLY') {
|
||||
const targets: number[] = [];
|
||||
if (byday.length > 0) {
|
||||
for (const code of byday) {
|
||||
const wd: number = RruleUtil.weekdayFromCode(code);
|
||||
if (wd >= 0) {
|
||||
targets.push(wd);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targets.length === 0) {
|
||||
targets.push(base.getDay());
|
||||
}
|
||||
const anchorWeek: number = RruleUtil.weekStartMs(dtstart);
|
||||
let dayMs: number = baseZeroMs;
|
||||
let guard: number = 0;
|
||||
while (dayMs <= windowEnd + DAY_MS && guard < 5000) {
|
||||
guard++;
|
||||
const wd: number = new Date(dayMs).getDay();
|
||||
if (targets.includes(wd)) {
|
||||
const weekIndex: number =
|
||||
Math.round((RruleUtil.weekStartMs(dayMs) - anchorWeek) / (7 * DAY_MS));
|
||||
if (weekIndex % interval === 0) {
|
||||
if (!accept(dayMs + timeOfDay)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
dayMs += DAY_MS;
|
||||
}
|
||||
} else if (freq === 'MONTHLY') {
|
||||
let y: number = base.getFullYear();
|
||||
let m: number = base.getMonth();
|
||||
let guard: number = 0;
|
||||
while (guard < 1500) {
|
||||
guard++;
|
||||
const monthFirst: number = new Date(y, m, 1).getTime();
|
||||
if (monthFirst > windowEnd + DAY_MS) {
|
||||
break;
|
||||
}
|
||||
const days: number[] = (bymonthday.length > 0 && !bymonthday.some((v: number): boolean => Number.isNaN(v)))
|
||||
? bymonthday : [base.getDate()];
|
||||
let stop: boolean = false;
|
||||
for (const md of days) {
|
||||
const dim: number = new Date(y, m + 1, 0).getDate();
|
||||
if (md >= 1 && md <= dim) {
|
||||
const occ: number = new Date(y, m, md,
|
||||
base.getHours(), base.getMinutes(), base.getSeconds()).getTime();
|
||||
if (!accept(occ)) {
|
||||
stop = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stop) {
|
||||
break;
|
||||
}
|
||||
m += interval;
|
||||
while (m > 11) {
|
||||
m -= 12;
|
||||
y++;
|
||||
}
|
||||
}
|
||||
} else if (freq === 'YEARLY') {
|
||||
let y: number = base.getFullYear();
|
||||
let guard: number = 0;
|
||||
while (guard < 300) {
|
||||
guard++;
|
||||
const occ: number = new Date(y, base.getMonth(), base.getDate(),
|
||||
base.getHours(), base.getMinutes(), base.getSeconds()).getTime();
|
||||
if (occ > windowEnd + DAY_MS) {
|
||||
break;
|
||||
}
|
||||
if (!accept(occ)) {
|
||||
break;
|
||||
}
|
||||
y += interval;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 当天 0 点 */
|
||||
static dayFloor(ms: number): number {
|
||||
const d = new Date(ms);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
/** 所在周的周一 0 点 */
|
||||
static weekStartMs(ms: number): number {
|
||||
const d = new Date(ms);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
const offset: number = (d.getDay() + 6) % 7;
|
||||
return d.getTime() - offset * DAY_MS;
|
||||
}
|
||||
|
||||
/** BYDAY 代码 → getDay() 星期值(0=周日);2TU 等带序号的取后两位 */
|
||||
static weekdayFromCode(code: string): number {
|
||||
if (code.startsWith('MO')) {
|
||||
return 1;
|
||||
}
|
||||
if (code.startsWith('TU')) {
|
||||
return 2;
|
||||
}
|
||||
if (code.startsWith('WE')) {
|
||||
return 3;
|
||||
}
|
||||
if (code.startsWith('TH')) {
|
||||
return 4;
|
||||
}
|
||||
if (code.startsWith('FR')) {
|
||||
return 5;
|
||||
}
|
||||
if (code.startsWith('SA')) {
|
||||
return 6;
|
||||
}
|
||||
if (code.startsWith('SU')) {
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** UNTIL 值:20260913T235959Z / 20260913T235959 / 20260913 */
|
||||
private static parseUntil(value: string): number {
|
||||
const v: string = value.trim().toUpperCase();
|
||||
if (/^\d{8}$/.test(v)) {
|
||||
return new Date(Number(v.substring(0, 4)), Number(v.substring(4, 6)) - 1,
|
||||
Number(v.substring(6, 8)), 23, 59, 59).getTime();
|
||||
}
|
||||
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/.exec(v);
|
||||
if (m === null) {
|
||||
return 0;
|
||||
}
|
||||
if (v.endsWith('Z')) {
|
||||
return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]),
|
||||
Number(m[4]), Number(m[5]), Number(m[6]));
|
||||
}
|
||||
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]),
|
||||
Number(m[4]), Number(m[5]), Number(m[6])).getTime();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// entry/src/main/ets/common/SyncEngine.ets
|
||||
// 双向同步引擎:先推本地修改(PUT/DELETE),再拉远端变更(REPORT + etag 增量)
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount } from './AccountStore';
|
||||
import { EventDb, LocalEvent, RemoteEvent } from './EventDb';
|
||||
import { IcsUtil } from './IcsUtil';
|
||||
import { DavClient, DavColorEntry } from './DavClient';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
export class SyncEngine {
|
||||
/**
|
||||
* 给异步操作加超时保护,防止网络挂起导致界面一直转圈
|
||||
*/
|
||||
static withTimeout<T>(task: Promise<T>, ms: number): Promise<T> {
|
||||
return Promise.race<T>([
|
||||
task,
|
||||
new Promise<T>((_resolve: (value: T) => void, reject: (reason?: Error) => void) => {
|
||||
setTimeout(() => reject(new Error(`同步超时(${Math.round(ms / 1000)}秒)`)), ms);
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步一个 CalDAV 账号(先推该账号的本地修改,再拉远端变更),
|
||||
* 返回远端事件总数(拉取侧)。带 120 秒超时保护。
|
||||
*/
|
||||
static async syncAccount(context: common.UIAbilityContext, acc: DavAccount): Promise<number> {
|
||||
const t0: number = Date.now();
|
||||
LogUtil.write(`========== 同步账号「${acc.name}」开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`);
|
||||
try {
|
||||
const r: number = await SyncEngine.withTimeout<number>(
|
||||
SyncEngine.syncAccountInner(context, acc), 120000);
|
||||
LogUtil.write(`同步账号「${acc.name}」完成:拉取 ${r} 条日程,耗时 ${Math.round((Date.now() - t0) / 1000)} 秒`);
|
||||
return r;
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`同步账号「${acc.name}」失败:${e.message}(耗时 ${Math.round((Date.now() - t0) / 1000)} 秒)`);
|
||||
throw new Error(e.message !== '' ? e.message : `错误码 ${e.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
private static async syncAccountInner(context: common.Context, acc: DavAccount): Promise<number> {
|
||||
const auth: string = DavClient.authHeader(acc.username, acc.password);
|
||||
// 0) 刷新服务器端日历本颜色(每次同步都校正)
|
||||
await SyncEngine.refreshCalendarColors(acc, auth);
|
||||
// 1) 推送该账号日历本下的本地修改
|
||||
await SyncEngine.pushDirtyForAccount(context, acc, auth);
|
||||
// 2) 拉取远端变更(全量 REPORT,etag 增量落库)
|
||||
let changed: number = 0;
|
||||
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
||||
const href: string = acc.calendarHrefs[i];
|
||||
const calKey: string = `${acc.id}_${i}`;
|
||||
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
|
||||
LogUtil.write(`日历本[${i}]「${calName}」开始同步:${href}`);
|
||||
const t1: number = Date.now();
|
||||
const items = await DavClient.reportCalendar(href, auth);
|
||||
LogUtil.write(`日历本[${i}]「${calName}」REPORT 返回 ${items.length} 个资源`);
|
||||
const remote: RemoteEvent[] = [];
|
||||
let parseFail: number = 0;
|
||||
let failSample: string = '';
|
||||
for (const it of items) {
|
||||
const parsed: RemoteEvent[] = IcsUtil.parse(it.ics);
|
||||
if (parsed.length === 0) {
|
||||
parseFail++;
|
||||
if (failSample === '') {
|
||||
failSample = it.ics.replace(/\r?\n/g, ' ⏎ ').substring(0, 600);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 一个资源可能包含主事件 + 单次覆盖实例(RECURRENCE-ID),全部入库
|
||||
for (const r of parsed) {
|
||||
if (r.uid === '') {
|
||||
r.uid = SyncEngine.uidFromHref(it.href);
|
||||
}
|
||||
r.etag = it.etag;
|
||||
remote.push(r);
|
||||
}
|
||||
}
|
||||
if (parseFail > 0) {
|
||||
LogUtil.write(`日历本[${i}]「${calName}」有 ${parseFail} 个资源解析出 0 条日程,首个样本: ${failSample}`);
|
||||
}
|
||||
LogUtil.write(`日历本[${i}]「${calName}」解析出 ${remote.length} 条日程,开始落库`);
|
||||
const stat: string = await EventDb.applyRemote(context, calKey, href, remote, false);
|
||||
LogUtil.write(`日历本[${i}]「${calName}」日程落库完成:${stat},耗时 ${Math.round((Date.now() - t1) / 1000)} 秒`);
|
||||
changed += remote.length;
|
||||
// 3) 拉取该日历本下的待办(VTODO,只读展示,拉取失败不影响日程同步)
|
||||
try {
|
||||
const todoItems = await DavClient.reportTodos(href, auth);
|
||||
const remoteTodos: RemoteEvent[] = [];
|
||||
for (const it of todoItems) {
|
||||
const parsedTodos: RemoteEvent[] = IcsUtil.parseTodos(it.ics);
|
||||
for (const t of parsedTodos) {
|
||||
if (t.uid === '') {
|
||||
t.uid = SyncEngine.uidFromHref(it.href);
|
||||
}
|
||||
t.etag = it.etag;
|
||||
remoteTodos.push(t);
|
||||
}
|
||||
}
|
||||
const tstat: string = await EventDb.applyRemote(context, calKey, href, remoteTodos, true);
|
||||
LogUtil.write(`日历本[${i}]「${calName}」待办:REPORT ${todoItems.length} 个资源,解析 ${remoteTodos.length} 条,${tstat}`);
|
||||
} catch (err) {
|
||||
const te = err as BusinessError;
|
||||
LogUtil.write(`日历本[${i}]「${calName}」拉取待办失败(忽略):${te.message}`);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors
|
||||
* 失败静默(颜色不影响数据正确性)
|
||||
*/
|
||||
static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<void> {
|
||||
try {
|
||||
const entries: DavColorEntry[] = await DavClient.propfindColors(acc.serverUrl, auth);
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
||||
const target: string = acc.calendarHrefs[i];
|
||||
const originMatch = /https?:\/\/[^/]+/i.exec(target);
|
||||
let path: string = originMatch !== null ? target.substring(originMatch[0].length) : target;
|
||||
if (path === '') {
|
||||
path = '/';
|
||||
}
|
||||
const norm = (s: string): string => s.endsWith('/') ? s : s + '/';
|
||||
const found = entries.find((e: DavColorEntry): boolean =>
|
||||
norm(e.href) === norm(path));
|
||||
if (found !== undefined && found.color !== '') {
|
||||
while (acc.calendarColors.length <= i) {
|
||||
acc.calendarColors.push('');
|
||||
}
|
||||
acc.calendarColors[i] = found.color;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.info(`刷新日历本颜色失败(忽略): ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 从资源 URL 提取 UID(解析失败时的兜底) */
|
||||
private static uidFromHref(href: string): string {
|
||||
const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
|
||||
if (segs.length === 0) {
|
||||
return String(Date.now());
|
||||
}
|
||||
const last: string = segs[segs.length - 1];
|
||||
return last.endsWith('.ics') ? last.substring(0, last.length - 4) : last;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送指定账号日历本下的待同步事件(新建/修改 → PUT;删除 → DELETE)
|
||||
*/
|
||||
static async pushDirtyForAccount(context: common.Context, acc: DavAccount, auth: string): Promise<void> {
|
||||
const dirty: LocalEvent[] = await EventDb.getDirty(context);
|
||||
const mine: LocalEvent[] = dirty.filter((e: LocalEvent): boolean => acc.calendarHrefs.includes(e.href));
|
||||
LogUtil.write(`推送本地修改:全部待推送 ${dirty.length} 条,属于账号「${acc.name}」的 ${mine.length} 条`);
|
||||
for (const e of mine) {
|
||||
if (e.kind === 'todo') {
|
||||
// 待办只读:本地不会有 dirty 待办,兜底清除
|
||||
await EventDb.clearDirty(context, e.id, e.etag);
|
||||
continue;
|
||||
}
|
||||
if (e.recurring) {
|
||||
// 重复日程实例推送会破坏服务器整个序列,暂不支持
|
||||
await EventDb.clearDirty(context, e.id, e.etag);
|
||||
LogUtil.write(`推送跳过重复日程实例「${e.title}」(uid=${e.uid})`);
|
||||
continue;
|
||||
}
|
||||
const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`;
|
||||
if (e.deleted) {
|
||||
await DavClient.deleteRemote(url, auth);
|
||||
await EventDb.purge(context, e.id);
|
||||
LogUtil.write(`推送删除「${e.title}」→ ${url}`);
|
||||
} else {
|
||||
const ics: string = IcsUtil.build(e);
|
||||
const etag: string = await DavClient.putEvent(url, auth, ics);
|
||||
await EventDb.clearDirty(context, e.id, etag);
|
||||
LogUtil.write(`推送保存「${e.title}」→ ${url}(${ics.length} 字节)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理本机虚拟日历(calKey=local)的待推送状态:
|
||||
* 本机事件不参与 DAV 同步,直接落地
|
||||
*/
|
||||
static async settleLocalEvents(context: common.Context): Promise<void> {
|
||||
const dirty: LocalEvent[] = await EventDb.getDirty(context);
|
||||
for (const e of dirty) {
|
||||
if (e.href !== '') {
|
||||
continue;
|
||||
}
|
||||
if (e.deleted) {
|
||||
await EventDb.purge(context, e.id);
|
||||
} else {
|
||||
await EventDb.clearDirty(context, e.id, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user