首次提交: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, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { window } from '@kit.ArkUI';
|
||||
|
||||
const DOMAIN = 0x0000;
|
||||
|
||||
export default class EntryAbility extends UIAbility {
|
||||
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy');
|
||||
}
|
||||
|
||||
onWindowStageCreate(windowStage: window.WindowStage): void {
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
|
||||
windowStage.loadContent('pages/Index', (err) => {
|
||||
if (err.code) {
|
||||
hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
|
||||
return;
|
||||
}
|
||||
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
|
||||
});
|
||||
try {
|
||||
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
|
||||
} catch (err) {
|
||||
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
|
||||
}
|
||||
}
|
||||
|
||||
onWindowStageDestroy(): void {
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy');
|
||||
}
|
||||
|
||||
onForeground(): void {
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
|
||||
}
|
||||
|
||||
onBackground(): void {
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { BackupExtensionAbility, BundleVersion } from '@kit.CoreFileKit';
|
||||
|
||||
const DOMAIN = 0x0000;
|
||||
|
||||
export default class EntryBackupAbility extends BackupExtensionAbility {
|
||||
async onBackup() {
|
||||
hilog.info(DOMAIN, 'testTag', 'onBackup ok');
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
async onRestore(bundleVersion: BundleVersion) {
|
||||
hilog.info(DOMAIN, 'testTag', 'onRestore ok %{public}s', JSON.stringify(bundleVersion));
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// entry/src/main/ets/entryformability/EntryFormAbility.ets
|
||||
// 服务卡片 FormExtensionAbility:添加卡片时注入初始数据,定时/触发时更新
|
||||
import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit';
|
||||
import { Want } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { CardDataService, CardData } from '../common/CardDataService';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
export default class EntryFormAbility extends FormExtensionAbility {
|
||||
/** 组装更新数据并推送到指定卡片 */
|
||||
private pushData(formId: string): void {
|
||||
CardDataService.buildCardData(this.context).then((data: CardData): void => {
|
||||
const binding: formBindingData.FormBindingData =
|
||||
formBindingData.createFormBindingData(data);
|
||||
formProvider.updateForm(formId, binding).catch((err: BusinessError): void => {
|
||||
LogUtil.write(`卡片 ${formId} 更新失败: ${err.message}`);
|
||||
});
|
||||
LogUtil.write(`卡片 ${formId} 数据已推送`);
|
||||
}).catch((err: BusinessError): void => {
|
||||
LogUtil.write(`卡片数据构建失败: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
onAddForm(want: Want): formBindingData.FormBindingData {
|
||||
LogUtil.init(this.context);
|
||||
let formId: string = '';
|
||||
if (want.parameters !== undefined) {
|
||||
const raw = want.parameters['ohos.extra.param.key.form_identity'];
|
||||
formId = typeof raw === 'string' ? raw : '';
|
||||
}
|
||||
LogUtil.write(`添加卡片: formId=${formId}`);
|
||||
// 先返回空壳初始数据,异步查库后推送真实数据
|
||||
if (formId !== '') {
|
||||
CardDataService.registerForm(this.context, formId);
|
||||
this.pushData(formId);
|
||||
}
|
||||
const initial = new CardData();
|
||||
return formBindingData.createFormBindingData(initial);
|
||||
}
|
||||
|
||||
onUpdateForm(formId: string): void {
|
||||
LogUtil.init(this.context);
|
||||
LogUtil.write(`系统触发卡片更新: formId=${formId}`);
|
||||
CardDataService.registerForm(this.context, formId);
|
||||
this.pushData(formId);
|
||||
}
|
||||
|
||||
onRemoveForm(formId: string): void {
|
||||
LogUtil.init(this.context);
|
||||
LogUtil.write(`移除卡片: formId=${formId}`);
|
||||
CardDataService.unregisterForm(this.context, formId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
// entry/src/main/ets/pages/AccountsPage.ets
|
||||
// DAV 账号管理(由原 CalDAVSync 首页移植)
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount, AccountStore, TYPE_KEYS, TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV } from '../common/AccountStore';
|
||||
import { SyncEngine } from '../common/SyncEngine';
|
||||
import { EditNavParams } from './EditAccountPage';
|
||||
import { EventDb } from '../common/EventDb';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct AccountsPage {
|
||||
@State accounts: DavAccount[] = [];
|
||||
@State showTypeMenu: boolean = false;
|
||||
@State syncing: boolean = false;
|
||||
@State syncingId: string = '';
|
||||
|
||||
aboutToAppear(): void {
|
||||
const ctx = this.getUIContext().getHostContext();
|
||||
if (ctx !== undefined) {
|
||||
LogUtil.init(ctx);
|
||||
LogUtil.write('---- 打开 DAV 账号页 ----');
|
||||
}
|
||||
this.reloadAccounts();
|
||||
}
|
||||
|
||||
onPageShow(): void {
|
||||
this.reloadAccounts();
|
||||
}
|
||||
|
||||
private async reloadAccounts(): Promise<void> {
|
||||
const context = this.getUIContext().getHostContext();
|
||||
if (context === undefined) {
|
||||
return;
|
||||
}
|
||||
this.accounts = await AccountStore.loadAll(context);
|
||||
const pendingId: string | undefined = AppStorage.get<string>('pendingSyncAccountId');
|
||||
if (pendingId !== undefined && pendingId !== '') {
|
||||
AppStorage.setOrCreate<string>('pendingSyncAccountId', '');
|
||||
const found = this.accounts.find((a: DavAccount): boolean => a.id === pendingId);
|
||||
if (found !== undefined) {
|
||||
this.syncSingleAccount(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private typeLabel(type: string): string {
|
||||
if (type === TYPE_CALDAV) {
|
||||
return 'CalDAV 日历';
|
||||
}
|
||||
if (type === TYPE_CARDDAV) {
|
||||
return 'CardDAV 通讯录';
|
||||
}
|
||||
return 'WebDAV 文件';
|
||||
}
|
||||
|
||||
private formatNow(): string {
|
||||
const d = new Date();
|
||||
const p = (n: number): string => n < 10 ? '0' + n : String(n);
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
private async syncSingleAccount(acc: DavAccount): Promise<void> {
|
||||
if (this.syncing) {
|
||||
return;
|
||||
}
|
||||
this.syncing = true;
|
||||
this.syncingId = acc.id;
|
||||
try {
|
||||
const context = this.getUIContext().getHostContext();
|
||||
if (context === undefined) {
|
||||
return;
|
||||
}
|
||||
if (acc.type === TYPE_CALDAV) {
|
||||
await SyncEngine.withTimeout(
|
||||
SyncEngine.syncAccount(context as common.UIAbilityContext, acc), 120000);
|
||||
}
|
||||
acc.itemCount = acc.calendarHrefs.length;
|
||||
acc.lastSyncTime = this.formatNow();
|
||||
await AccountStore.saveAll(context, this.accounts);
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: `「${acc.name}」同步完成` });
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: `「${acc.name}」同步失败:${e.message}` });
|
||||
} finally {
|
||||
this.syncing = false;
|
||||
this.syncingId = '';
|
||||
}
|
||||
}
|
||||
|
||||
private openAddPage(type: string): void {
|
||||
this.showTypeMenu = false;
|
||||
AppStorage.setOrCreate<string>('pendingAccountType', type);
|
||||
router.pushUrl({ url: 'pages/AddAccountPage' });
|
||||
}
|
||||
|
||||
/** 点击账号 → 进入编辑页(查看/重选日历本) */
|
||||
private openEditPage(acc: DavAccount): void {
|
||||
if (acc.type !== TYPE_CALDAV) {
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: '该类型账号暂不支持编辑日历本' });
|
||||
return;
|
||||
}
|
||||
AppStorage.setOrCreate<string>('pendingEditAccountId', acc.id);
|
||||
const params = new EditNavParams();
|
||||
params.accId = acc.id;
|
||||
router.pushUrl({ url: 'pages/EditAccountPage', params: params });
|
||||
}
|
||||
|
||||
/** 长按账号 → 弹出删除确认 */
|
||||
private askDeleteAccount(acc: DavAccount): void {
|
||||
this.getUIContext().showAlertDialog({
|
||||
title: '删除账号',
|
||||
message: `确定删除「${acc.name}」吗?\n\n该账号下的所有日历本、日程与待办的本地数据将一并删除。\n服务器上的数据不受影响。`,
|
||||
autoCancel: true,
|
||||
alignment: DialogAlignment.Center,
|
||||
primaryButton: {
|
||||
value: '取消',
|
||||
action: (): void => {}
|
||||
},
|
||||
secondaryButton: {
|
||||
value: '删除',
|
||||
fontColor: $r('app.color.error'),
|
||||
action: (): void => {
|
||||
this.confirmDeleteAccount(acc);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 执行删除:账号配置 + 该账号全部本地日程/待办 */
|
||||
private async confirmDeleteAccount(acc: DavAccount): Promise<void> {
|
||||
const context = this.getUIContext().getHostContext();
|
||||
if (context === undefined) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.accounts = this.accounts.filter((a: DavAccount): boolean => a.id !== acc.id);
|
||||
await AccountStore.saveAll(context, this.accounts);
|
||||
await EventDb.deleteAccountEvents(context, acc.id);
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: `「${acc.name}」已删除` });
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: `删除失败:${e.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.BottomEnd }) {
|
||||
Column() {
|
||||
// 顶部
|
||||
Row({ space: 10 }) {
|
||||
Text('←')
|
||||
.fontSize(20)
|
||||
.fontColor($r('app.color.brand'))
|
||||
.onClick(() => {
|
||||
router.back();
|
||||
})
|
||||
Text('DAV 账号')
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 20, right: 20, top: 12, bottom: 8 })
|
||||
|
||||
if (this.accounts.length === 0) {
|
||||
this.emptyState()
|
||||
} else {
|
||||
Scroll() {
|
||||
Column({ space: 16 }) {
|
||||
ForEach(TYPE_KEYS, (type: string) => {
|
||||
if (this.accounts.some((a: DavAccount): boolean => a.type === type)) {
|
||||
Column({ space: 8 }) {
|
||||
Text(this.typeLabel(type))
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
ForEach(this.accounts.filter((a: DavAccount): boolean => a.type === type),
|
||||
(acc: DavAccount) => {
|
||||
AccountRow({
|
||||
acc: acc,
|
||||
isSyncing: this.syncingId === acc.id,
|
||||
onSelect: (selected: DavAccount): void => {
|
||||
this.openEditPage(selected);
|
||||
},
|
||||
onLongPress: (selected: DavAccount): void => {
|
||||
this.askDeleteAccount(selected);
|
||||
}
|
||||
})
|
||||
}, (acc: DavAccount) => acc.id)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.width('100%')
|
||||
}
|
||||
}, (type: string) => type)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 20, right: 20, bottom: 100 })
|
||||
.constraintSize({ minHeight: '100%' })
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Off)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.align(Alignment.Top)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor($r('app.color.page_bg'))
|
||||
|
||||
if (this.showTypeMenu) {
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.onClick(() => {
|
||||
this.showTypeMenu = false;
|
||||
})
|
||||
}
|
||||
|
||||
if (this.showTypeMenu) {
|
||||
Column({ space: 10 }) {
|
||||
this.menuItem('日', 'CalDAV', '日历同步', TYPE_CALDAV)
|
||||
this.menuItem('人', 'CardDAV', '通讯录同步', TYPE_CARDDAV)
|
||||
this.menuItem('文', 'WebDAV', '文件访问', TYPE_WEBDAV)
|
||||
}
|
||||
.width(220)
|
||||
.padding(10)
|
||||
.borderRadius(16)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.shadow({ radius: 16, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 4 })
|
||||
.margin({ right: 24, bottom: 156 })
|
||||
}
|
||||
|
||||
Button() {
|
||||
Text('+')
|
||||
.fontSize(26)
|
||||
.fontColor($r('app.color.button_text'))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
}
|
||||
.width(56)
|
||||
.height(56)
|
||||
.borderRadius(28)
|
||||
.backgroundColor($r('app.color.brand'))
|
||||
.shadow({ radius: 8, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 2 })
|
||||
.margin(24)
|
||||
.onClick(() => {
|
||||
this.showTypeMenu = !this.showTypeMenu;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
|
||||
@Builder
|
||||
emptyState() {
|
||||
Column({ space: 12 }) {
|
||||
Text('+')
|
||||
.fontSize(30)
|
||||
.fontColor($r('app.color.text_hint'))
|
||||
.width(72)
|
||||
.height(72)
|
||||
.textAlign(TextAlign.Center)
|
||||
.borderRadius(20)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.border({ width: 1.5, color: $r('app.color.shadow_color') })
|
||||
Text('还没有任何 DAV 账号')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text('点击右下角 + 添加 CalDAV / CardDAV / WebDAV 账号')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.textAlign(TextAlign.Center)
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
|
||||
@Builder
|
||||
menuItem(badge: string, title: string, desc: string, type: string) {
|
||||
Row({ space: 12 }) {
|
||||
Text(badge)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.brand'))
|
||||
.width(40)
|
||||
.height(40)
|
||||
.textAlign(TextAlign.Center)
|
||||
.borderRadius(10)
|
||||
.backgroundColor($r('app.color.input_bg'))
|
||||
Column({ space: 2 }) {
|
||||
Text(title)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(desc)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(8)
|
||||
.borderRadius(10)
|
||||
.onClick(() => {
|
||||
this.openAddPage(type);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
struct AccountRow {
|
||||
@ObjectLink acc: DavAccount;
|
||||
@Prop isSyncing: boolean = false;
|
||||
onSelect: (acc: DavAccount) => void = (selected: DavAccount): void => {};
|
||||
onLongPress: (acc: DavAccount) => void = (selected: DavAccount): void => {};
|
||||
|
||||
build() {
|
||||
Row({ space: 12 }) {
|
||||
Text(this.acc.name !== '' ? this.acc.name.substring(0, 1) : 'D')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.button_text'))
|
||||
.width(40)
|
||||
.height(40)
|
||||
.textAlign(TextAlign.Center)
|
||||
.borderRadius(10)
|
||||
.backgroundColor($r('app.color.brand'))
|
||||
Column({ space: 4 }) {
|
||||
Text(this.acc.name)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(this.acc.serverUrl)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(this.acc.lastSyncTime === ''
|
||||
? '尚未同步'
|
||||
: `上次同步 ${this.acc.lastSyncTime} · ${this.acc.calendarHrefs.length} 个日历本`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_hint'))
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
|
||||
if (this.isSyncing) {
|
||||
LoadingProgress()
|
||||
.width(20)
|
||||
.height(20)
|
||||
.color($r('app.color.brand'))
|
||||
} else {
|
||||
Text('›')
|
||||
.fontSize(20)
|
||||
.fontColor($r('app.color.text_hint'))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.borderRadius(12)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||||
.onClick(() => {
|
||||
this.onSelect(this.acc);
|
||||
})
|
||||
.gesture(LongPressGesture({ repeat: false })
|
||||
.onAction((event: GestureEvent) => {
|
||||
this.onLongPress(this.acc);
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// entry/src/main/ets/pages/AddAccountPage.ets
|
||||
// 添加账号第一页:URL / 用户名 / 密码 → 连接(凭据经 AppStorage 传给日历本选择页)
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { buffer } from '@kit.ArkTS';
|
||||
import url from '@ohos.url';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV } from '../common/AccountStore';
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct AddAccountPage {
|
||||
@State serverUrl: string = '';
|
||||
@State username: string = '';
|
||||
@State password: string = '';
|
||||
@State isLoading: boolean = false;
|
||||
@State statusMsg: string = '';
|
||||
@State statusOk: boolean = false;
|
||||
@State typeLabel: string = 'CalDAV';
|
||||
private accountType: string = TYPE_CALDAV;
|
||||
|
||||
aboutToAppear(): void {
|
||||
const t: string | undefined = AppStorage.get<string>('pendingAccountType');
|
||||
this.accountType = (t === undefined || t === '') ? TYPE_CALDAV : t;
|
||||
if (this.accountType === TYPE_CARDDAV) {
|
||||
this.typeLabel = 'CardDAV';
|
||||
} else if (this.accountType === TYPE_WEBDAV) {
|
||||
this.typeLabel = 'WebDAV';
|
||||
} else {
|
||||
this.typeLabel = 'CalDAV';
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeUrl(): string | null {
|
||||
let rawUrl: string = this.serverUrl.trim();
|
||||
if (rawUrl === '') {
|
||||
return null;
|
||||
}
|
||||
if (!rawUrl.startsWith('http://') && !rawUrl.startsWith('https://')) {
|
||||
rawUrl = 'https://' + rawUrl;
|
||||
}
|
||||
const hostPattern: RegExp =
|
||||
/^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$|^\d{1,3}(\.\d{1,3}){3}$|^\[[0-9A-Fa-f:]+\]$|^localhost$/;
|
||||
try {
|
||||
const parsed = url.URL.parseURL(rawUrl);
|
||||
const hostname: string = parsed.hostname !== '' ? parsed.hostname : parsed.host;
|
||||
if (hostname !== '' && hostPattern.test(hostname)) {
|
||||
return rawUrl;
|
||||
}
|
||||
this.statusMsg = `URL 主机名无效:${rawUrl}`;
|
||||
this.statusOk = false;
|
||||
return null;
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`URL 解析异常(${e.code}),使用正则兜底: ${e.message}`);
|
||||
const fallbackPattern: RegExp =
|
||||
/^https?:\/\/[^\s/:?#]+(:\d{1,5})?([/?#][^\s]*)?$/;
|
||||
if (fallbackPattern.test(rawUrl)) {
|
||||
return rawUrl;
|
||||
}
|
||||
this.statusMsg = `URL 解析失败:${e.message}`;
|
||||
this.statusOk = false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private encodeBasicAuth(): string {
|
||||
try {
|
||||
return buffer.from(`${this.username}:${this.password}`).toString('base64');
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`Base64 编码失败: ${e.message}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private async sendOnce(serverUrl: string, method: http.RequestMethod, authHeader: string): Promise<number> {
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const resp: http.HttpResponse = await httpRequest.request(serverUrl, {
|
||||
method: method,
|
||||
header: {
|
||||
'Authorization': authHeader,
|
||||
'Accept': '*/*',
|
||||
'User-Agent': 'SyncCalendar/1.0'
|
||||
},
|
||||
connectTimeout: 10000,
|
||||
readTimeout: 10000
|
||||
});
|
||||
return resp.responseCode;
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private async probeServer(serverUrl: string): Promise<boolean> {
|
||||
const token: string = this.encodeBasicAuth();
|
||||
if (token === '') {
|
||||
this.statusMsg = '凭据编码失败:请在真机或模拟器上运行';
|
||||
this.statusOk = false;
|
||||
return false;
|
||||
}
|
||||
const authHeader: string = 'Basic ' + token;
|
||||
try {
|
||||
let code: number = await this.sendOnce(serverUrl, http.RequestMethod.OPTIONS, authHeader);
|
||||
if (code === 401) {
|
||||
code = await this.sendOnce(serverUrl, http.RequestMethod.GET, authHeader);
|
||||
}
|
||||
if (code === 401) {
|
||||
this.statusMsg = '服务器拒绝凭据(401),请检查用户名密码';
|
||||
this.statusOk = false;
|
||||
return false;
|
||||
}
|
||||
if (code >= 200 && code < 500) {
|
||||
this.statusMsg = '服务器连接成功';
|
||||
this.statusOk = true;
|
||||
return true;
|
||||
}
|
||||
this.statusMsg = `服务器返回异常状态码:${code}`;
|
||||
this.statusOk = false;
|
||||
return false;
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`连接失败: ${e.code} - ${e.message}`);
|
||||
this.statusMsg = `无法连接服务器:${e.message}`;
|
||||
this.statusOk = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async onConnectAndSave(): Promise<void> {
|
||||
if (this.isLoading) {
|
||||
return;
|
||||
}
|
||||
const targetUrl: string | null = this.normalizeUrl();
|
||||
if (targetUrl === null) {
|
||||
if (this.statusMsg === '' || this.statusMsg === '正在连接服务器…' || this.statusOk) {
|
||||
this.statusMsg = '请输入有效的服务器地址';
|
||||
}
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
if (!this.username.trim()) {
|
||||
this.statusMsg = '请输入用户名';
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
if (!this.password) {
|
||||
this.statusMsg = '请输入密码';
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.statusMsg = '正在连接服务器…';
|
||||
this.statusOk = false;
|
||||
|
||||
const ok: boolean = await this.probeServer(targetUrl);
|
||||
if (ok) {
|
||||
AppStorage.setOrCreate<string>('pendingDavUrl', targetUrl);
|
||||
AppStorage.setOrCreate<string>('pendingDavUsername', this.username.trim());
|
||||
AppStorage.setOrCreate<string>('pendingDavPassword', this.password);
|
||||
this.getUIContext().getPromptAction().showToast({ message: '连接成功' });
|
||||
router.pushUrl({ url: 'pages/CalendarListPage' });
|
||||
}
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
build() {
|
||||
Column({ space: 24 }) {
|
||||
Row({ space: 6 }) {
|
||||
Text('←')
|
||||
.fontSize(18)
|
||||
.fontColor($r('app.color.brand'))
|
||||
Text('返回')
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.brand'))
|
||||
}
|
||||
.width('100%')
|
||||
.onClick(() => {
|
||||
router.back();
|
||||
})
|
||||
|
||||
Column({ space: 8 }) {
|
||||
Text(`添加${this.typeLabel}账号`)
|
||||
.fontSize(26)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text('输入服务器账号信息')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.width('100%')
|
||||
|
||||
Column({ space: 16 }) {
|
||||
this.formField('服务器地址', '例如:https://nas.example.com/caldav/', this.serverUrl,
|
||||
(value: string) => {
|
||||
this.serverUrl = value;
|
||||
}, false)
|
||||
this.formField('用户名', '请输入用户名', this.username,
|
||||
(value: string) => {
|
||||
this.username = value;
|
||||
}, false)
|
||||
this.formField('密码', '请输入密码', this.password,
|
||||
(value: string) => {
|
||||
this.password = value;
|
||||
}, true)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(20)
|
||||
.borderRadius(16)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.shadow({ radius: 12, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 4 })
|
||||
|
||||
Button() {
|
||||
Row({ space: 8 }) {
|
||||
if (this.isLoading) {
|
||||
LoadingProgress()
|
||||
.width(20)
|
||||
.height(20)
|
||||
.color($r('app.color.button_text'))
|
||||
}
|
||||
Text(this.isLoading ? '连接中…' : '连接并保存')
|
||||
.fontSize(17)
|
||||
.fontColor($r('app.color.button_text'))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.borderRadius(24)
|
||||
.backgroundColor(this.isLoading ? $r('app.color.brand_disabled') : $r('app.color.brand'))
|
||||
.enabled(!this.isLoading)
|
||||
.onClick(() => {
|
||||
this.onConnectAndSave();
|
||||
})
|
||||
|
||||
if (this.statusMsg) {
|
||||
Row({ space: 6 }) {
|
||||
Text(this.statusOk ? '✓' : '✕')
|
||||
.fontSize(14)
|
||||
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
|
||||
Text(this.statusMsg)
|
||||
.fontSize(14)
|
||||
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.borderRadius(8)
|
||||
.backgroundColor(this.statusOk ? $r('app.color.success_bg') : $r('app.color.error_bg'))
|
||||
}
|
||||
|
||||
Blank()
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding({ left: 24, right: 24, top: 16, bottom: 24 })
|
||||
.backgroundColor($r('app.color.page_bg'))
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
|
||||
@Builder
|
||||
formField(label: string, placeholder: string, value: string,
|
||||
onChange: (value: string) => void, isPassword: boolean) {
|
||||
Column({ space: 8 }) {
|
||||
Text(label)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
TextInput({ text: value, placeholder: placeholder })
|
||||
.type(isPassword ? InputType.Password : InputType.Normal)
|
||||
.showPasswordIcon(isPassword)
|
||||
.height(44)
|
||||
.fontSize(15)
|
||||
.backgroundColor($r('app.color.input_bg'))
|
||||
.borderRadius(8)
|
||||
.onChange(onChange)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
// entry/src/main/ets/pages/CalendarListPage.ets
|
||||
// 添加账号第二页:PROPFIND 列出日历本 → 勾选 → 命名 → 保存账号
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { buffer } from '@kit.ArkTS';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
/**
|
||||
* 日历本条目(@Observed 使勾选状态变化能刷新 UI)
|
||||
*/
|
||||
@Observed
|
||||
export class CalendarItem {
|
||||
href: string;
|
||||
name: string;
|
||||
color: string; // 服务器定义的颜色(calendar-color),可能为空
|
||||
selected: boolean;
|
||||
|
||||
constructor(href: string, name: string, color: string) {
|
||||
this.href = href;
|
||||
this.name = name;
|
||||
this.color = color;
|
||||
this.selected = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct CalendarListPage {
|
||||
@State calendarList: CalendarItem[] = [];
|
||||
@State accountName: string = '';
|
||||
@State isLoading: boolean = true;
|
||||
@State statusMsg: string = '正在获取日历列表…';
|
||||
@State statusOk: boolean = false;
|
||||
@State isSaving: boolean = false;
|
||||
@State selectedCount: number = 0;
|
||||
@State allSelected: boolean = false;
|
||||
private serverUrl: string = '';
|
||||
private username: string = '';
|
||||
private password: string = '';
|
||||
|
||||
aboutToAppear(): Promise<void> {
|
||||
return this.initPage();
|
||||
}
|
||||
|
||||
private async initPage(): Promise<void> {
|
||||
const ctx = this.getUIContext().getHostContext();
|
||||
if (ctx !== undefined) {
|
||||
LogUtil.init(ctx);
|
||||
}
|
||||
this.serverUrl = AppStorage.get<string>('pendingDavUrl') ?? '';
|
||||
this.username = AppStorage.get<string>('pendingDavUsername') ?? '';
|
||||
this.password = AppStorage.get<string>('pendingDavPassword') ?? '';
|
||||
LogUtil.write(`添加账号流程开始:服务器=${this.serverUrl} 用户名=${this.username}`);
|
||||
if (this.serverUrl === '') {
|
||||
this.isLoading = false;
|
||||
this.statusMsg = '尚未连接服务器,请先返回重新连接';
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
await this.fetchCalendars();
|
||||
}
|
||||
|
||||
private encodeBasicAuth(): string {
|
||||
try {
|
||||
return buffer.from(`${this.username}:${this.password}`).toString('base64');
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`Base64 编码失败: ${e.message}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchCalendars(): Promise<void> {
|
||||
const token: string = this.encodeBasicAuth();
|
||||
if (token === '') {
|
||||
this.isLoading = false;
|
||||
this.statusMsg = '凭据编码失败:请在真机或模拟器上运行';
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
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:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
|
||||
'</d:prop></d:propfind>';
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const resp: http.HttpResponse = await httpRequest.request(this.serverUrl, {
|
||||
method: 'PROPFIND' as http.RequestMethod,
|
||||
header: {
|
||||
'Authorization': 'Basic ' + token,
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Depth': '1',
|
||||
'User-Agent': 'SyncCalendar/1.0'
|
||||
},
|
||||
extraData: requestBody,
|
||||
connectTimeout: 10000,
|
||||
readTimeout: 15000
|
||||
});
|
||||
console.info(`PROPFIND 响应码: ${resp.responseCode}`);
|
||||
if (resp.responseCode === 401) {
|
||||
this.isLoading = false;
|
||||
this.statusMsg = '登录已失效,请返回重新连接';
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
if (resp.responseCode !== 207 && resp.responseCode !== 200) {
|
||||
this.isLoading = false;
|
||||
this.statusMsg = `获取日历列表失败,服务器返回:${resp.responseCode}`;
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
const xml: string = resp.result as string;
|
||||
LogUtil.write(`添加账号 PROPFIND → ${resp.responseCode},响应体 ${xml.length} 字符`);
|
||||
const list: CalendarItem[] = this.parseCalendarList(xml);
|
||||
for (const item of list) {
|
||||
LogUtil.write(`发现日历本:「${item.name}」${item.href} 颜色=${item.color === '' ? '(无)' : item.color}`);
|
||||
}
|
||||
this.isLoading = false;
|
||||
if (list.length === 0) {
|
||||
this.statusMsg = '该路径下未发现日历本(没有包含 calendar 资源类型的集合)';
|
||||
this.statusOk = false;
|
||||
} else {
|
||||
this.calendarList = list;
|
||||
this.updateSelectionState();
|
||||
this.statusMsg = `发现 ${list.length} 个日历本,请勾选要同步的日历`;
|
||||
this.statusOk = true;
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`获取日历列表失败: ${e.code} - ${e.message}`);
|
||||
this.isLoading = false;
|
||||
this.statusMsg = `获取日历列表失败:${e.message}`;
|
||||
this.statusOk = false;
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private 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() : '';
|
||||
}
|
||||
|
||||
private parseCalendarList(xml: string): CalendarItem[] {
|
||||
const items: CalendarItem[] = [];
|
||||
const originMatch = /https?:\/\/[^/]+/i.exec(this.serverUrl);
|
||||
const origin: string = originMatch !== null ? originMatch[0] : '';
|
||||
const blocks: string[] = xml.split(/<\/[\w-]*:?response>/i);
|
||||
for (const block of blocks) {
|
||||
if (!/<[\w-]*:?response[\s>]/i.test(block)) {
|
||||
continue;
|
||||
}
|
||||
const href: string = this.extractTag(block, 'href');
|
||||
if (href === '') {
|
||||
continue;
|
||||
}
|
||||
const resourcetype: string = this.extractTag(block, 'resourcetype');
|
||||
if (!/calendar/i.test(resourcetype)) {
|
||||
continue;
|
||||
}
|
||||
let name: string = this.extractTag(block, 'displayname');
|
||||
if (name === '') {
|
||||
const segs: string[] = href.split('/').filter((s: string) => s !== '');
|
||||
if (segs.length > 0) {
|
||||
try {
|
||||
name = decodeURIComponent(segs[segs.length - 1]);
|
||||
} catch (err) {
|
||||
name = segs[segs.length - 1];
|
||||
}
|
||||
} else {
|
||||
name = href;
|
||||
}
|
||||
}
|
||||
// 服务器端颜色:cs:getcolor 或 ical:calendar-color,带 Alpha 时转成 #RRGGBB
|
||||
let color: string = AccountStore.normalizeColor(this.extractTag(block, 'getcolor'));
|
||||
if (color === '') {
|
||||
color = AccountStore.normalizeColor(this.extractTag(block, 'calendar-color'));
|
||||
}
|
||||
const fullHref: string = href.startsWith('http') ? href : origin + href;
|
||||
items.push(new CalendarItem(fullHref, name, color));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private async saveSelection(): Promise<void> {
|
||||
if (this.isSaving) {
|
||||
return;
|
||||
}
|
||||
if (this.accountName.trim() === '') {
|
||||
this.statusMsg = '请先给这个日历账户起一个名字';
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
const selectedItems: CalendarItem[] = this.calendarList.filter((c: CalendarItem) => c.selected);
|
||||
if (selectedItems.length === 0) {
|
||||
this.statusMsg = '请至少勾选一个日历本';
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
this.isSaving = true;
|
||||
try {
|
||||
const context = this.getUIContext().getHostContext();
|
||||
if (context === undefined) {
|
||||
this.statusMsg = '无法获取应用上下文';
|
||||
this.statusOk = false;
|
||||
this.isSaving = false;
|
||||
return;
|
||||
}
|
||||
const acc = new DavAccount();
|
||||
acc.id = String(Date.now());
|
||||
const accType: string | undefined = AppStorage.get<string>('pendingAccountType');
|
||||
acc.type = (accType === undefined || accType === '') ? TYPE_CALDAV : accType;
|
||||
acc.name = this.accountName.trim();
|
||||
acc.serverUrl = this.serverUrl;
|
||||
acc.username = this.username;
|
||||
acc.password = this.password;
|
||||
acc.calendarHrefs = selectedItems.map((c: CalendarItem): string => c.href);
|
||||
acc.calendarNames = selectedItems.map((c: CalendarItem): string => c.name);
|
||||
acc.calendarColors = selectedItems.map((c: CalendarItem): string => c.color);
|
||||
LogUtil.write(`保存账号「${acc.name}」:id=${acc.id},勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
|
||||
await AccountStore.addAccount(context, acc);
|
||||
AppStorage.setOrCreate<string>('pendingSyncAccountId', acc.id);
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: `账号已保存,共 ${selectedItems.length} 个日历本` });
|
||||
this.statusMsg = '保存成功';
|
||||
this.statusOk = true;
|
||||
router.back({ url: 'pages/AccountsPage' });
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存失败: ${e.code} - ${e.message}`);
|
||||
this.statusMsg = `保存失败:${e.message}`;
|
||||
this.statusOk = false;
|
||||
}
|
||||
this.isSaving = false;
|
||||
}
|
||||
|
||||
private updateSelectionState(): void {
|
||||
const count: number = this.calendarList.filter((c: CalendarItem): boolean => c.selected).length;
|
||||
this.selectedCount = count;
|
||||
this.allSelected = this.calendarList.length > 0 && count === this.calendarList.length;
|
||||
}
|
||||
|
||||
private handleItemToggle(item: CalendarItem): void {
|
||||
item.selected = !item.selected;
|
||||
this.updateSelectionState();
|
||||
}
|
||||
|
||||
private toggleAll(): void {
|
||||
const target: boolean = !this.allSelected;
|
||||
this.calendarList.forEach((c: CalendarItem) => {
|
||||
c.selected = target;
|
||||
});
|
||||
this.updateSelectionState();
|
||||
}
|
||||
|
||||
build() {
|
||||
Column({ space: 14 }) {
|
||||
Row({ space: 6 }) {
|
||||
Text('←')
|
||||
.fontSize(18)
|
||||
.fontColor($r('app.color.brand'))
|
||||
Text('返回')
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.brand'))
|
||||
}
|
||||
.width('100%')
|
||||
.onClick(() => {
|
||||
router.back();
|
||||
})
|
||||
|
||||
Column({ space: 6 }) {
|
||||
Text('选择日历本')
|
||||
.fontSize(26)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text('勾选需要同步的日历本,并为账户命名')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.width('100%')
|
||||
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 4 }) {
|
||||
Text('日历账户名称')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
Text('*')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.error'))
|
||||
}
|
||||
TextInput({ text: this.accountName, placeholder: '例如:我的群晖日历' })
|
||||
.height(46)
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.placeholderColor($r('app.color.text_hint'))
|
||||
.backgroundColor($r('app.color.input_bg'))
|
||||
.borderRadius(10)
|
||||
.border({ width: 1.5, color: $r('app.color.brand') })
|
||||
.padding({ left: 12, right: 12 })
|
||||
.onChange((value: string) => {
|
||||
this.accountName = value;
|
||||
})
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(14)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||||
|
||||
Row({ space: 8 }) {
|
||||
Text(this.calendarList.length > 0
|
||||
? `已选 ${this.selectedCount} / ${this.calendarList.length}` : ' ')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
if (this.calendarList.length > 0) {
|
||||
Button(this.allSelected ? '取消全选' : '全选')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.brand'))
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: $r('app.color.brand'), radius: 14 })
|
||||
.height(30)
|
||||
.padding({ left: 14, right: 14 })
|
||||
.onClick(() => {
|
||||
this.toggleAll();
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Scroll() {
|
||||
Column({ space: 4 }) {
|
||||
if (this.isLoading) {
|
||||
Column({ space: 12 }) {
|
||||
LoadingProgress()
|
||||
.width(36)
|
||||
.height(36)
|
||||
.color($r('app.color.brand'))
|
||||
Text('正在从服务器获取日历列表…')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(32)
|
||||
} else if (this.calendarList.length === 0) {
|
||||
Text(this.statusMsg)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(24)
|
||||
.textAlign(TextAlign.Center)
|
||||
} else {
|
||||
ForEach(this.calendarList, (item: CalendarItem) => {
|
||||
CalendarRow({
|
||||
item: item,
|
||||
onSelect: (selectedItem: CalendarItem): void => {
|
||||
this.handleItemToggle(selectedItem);
|
||||
}
|
||||
})
|
||||
}, (item: CalendarItem) => item.href)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(8)
|
||||
.constraintSize({ minHeight: '100%' })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.align(Alignment.Top)
|
||||
.borderRadius(16)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.shadow({ radius: 12, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 4 })
|
||||
|
||||
if (this.statusMsg !== '' && !this.isLoading) {
|
||||
Row({ space: 6 }) {
|
||||
Text(this.statusOk ? '✓' : '✕')
|
||||
.fontSize(14)
|
||||
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
|
||||
Text(this.statusMsg)
|
||||
.fontSize(14)
|
||||
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.borderRadius(8)
|
||||
.backgroundColor(this.statusOk ? $r('app.color.success_bg') : $r('app.color.error_bg'))
|
||||
}
|
||||
|
||||
Button() {
|
||||
Text(this.isSaving ? '保存中…' : '保存并完成')
|
||||
.fontSize(17)
|
||||
.fontColor($r('app.color.button_text'))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
}
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.borderRadius(24)
|
||||
.backgroundColor(this.isSaving ? $r('app.color.brand_disabled') : $r('app.color.brand'))
|
||||
.enabled(!this.isSaving && !this.isLoading)
|
||||
.onClick(() => {
|
||||
this.saveSelection();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding({ left: 24, right: 24, top: 16, bottom: 24 })
|
||||
.backgroundColor($r('app.color.page_bg'))
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
struct CalendarRow {
|
||||
@ObjectLink item: CalendarItem;
|
||||
onSelect: (item: CalendarItem) => void = (selectedItem: CalendarItem): void => {};
|
||||
|
||||
build() {
|
||||
Row({ space: 12 }) {
|
||||
Text(this.item.name)
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Blank()
|
||||
if (this.item.selected) {
|
||||
Text('✓')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.button_text'))
|
||||
.width(24)
|
||||
.height(24)
|
||||
.textAlign(TextAlign.Center)
|
||||
.borderRadius(12)
|
||||
.backgroundColor($r('app.color.brand'))
|
||||
} else {
|
||||
Text('')
|
||||
.width(24)
|
||||
.height(24)
|
||||
.borderRadius(12)
|
||||
.border({ width: 1.5, color: $r('app.color.text_hint') })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 12, right: 12, top: 14, bottom: 14 })
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
this.onSelect(this.item);
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
// entry/src/main/ets/pages/EditAccountPage.ets
|
||||
// 编辑账号:查看/重选该账号下的日历本、修改账户名
|
||||
// 保存后清理失效日历本的本地数据,并触发一次重新同步
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { buffer } from '@kit.ArkTS';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount, AccountStore } from '../common/AccountStore';
|
||||
import { EventDb } from '../common/EventDb';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
/**
|
||||
* 日历本条目(@Observed 使勾选状态变化能刷新 UI)
|
||||
*/
|
||||
@Observed
|
||||
export class EditCalendarItem {
|
||||
href: string;
|
||||
name: string;
|
||||
color: string; // 服务器定义的颜色(calendar-color),可能为空
|
||||
selected: boolean;
|
||||
|
||||
constructor(href: string, name: string, color: string) {
|
||||
this.href = href;
|
||||
this.name = name;
|
||||
this.color = color;
|
||||
this.selected = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由参数(AppStorage 的兜底通道)
|
||||
*/
|
||||
export class EditNavParams {
|
||||
accId: string = '';
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct EditAccountPage {
|
||||
@State accountName: string = '';
|
||||
@State serverUrl: string = '';
|
||||
@State username: string = '';
|
||||
@State calendarList: EditCalendarItem[] = [];
|
||||
@State isLoading: boolean = true;
|
||||
@State statusMsg: string = '正在获取日历列表…';
|
||||
@State statusOk: boolean = false;
|
||||
@State isSaving: boolean = false;
|
||||
@State selectedCount: number = 0;
|
||||
@State allSelected: boolean = false;
|
||||
private acc: DavAccount = new DavAccount();
|
||||
private found: boolean = false;
|
||||
|
||||
aboutToAppear(): Promise<void> {
|
||||
return this.initPage();
|
||||
}
|
||||
|
||||
private async initPage(): Promise<void> {
|
||||
try {
|
||||
// 1) 读取目标账号 id(AppStorage 为主,路由参数兜底)
|
||||
let accId: string | undefined = AppStorage.get<string>('pendingEditAccountId');
|
||||
if (accId === undefined || accId === '') {
|
||||
const rawParams: Object | undefined = router.getParams();
|
||||
if (rawParams instanceof EditNavParams && rawParams.accId !== '') {
|
||||
accId = rawParams.accId;
|
||||
}
|
||||
}
|
||||
console.info(`编辑账号 initPage: accId=${accId ?? '(undefined)'}`);
|
||||
if (accId === undefined || accId === '') {
|
||||
this.isLoading = false;
|
||||
this.statusMsg = '未指定要编辑的账号,请返回账号列表重新点击';
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
// 2) 获取应用上下文(getHostContext 过早调用可能为 undefined,getContext 兜底)
|
||||
let context: common.Context | undefined = undefined;
|
||||
try {
|
||||
context = this.getUIContext().getHostContext();
|
||||
} catch (err) {
|
||||
console.info('getHostContext 异常,使用 getContext 兜底');
|
||||
}
|
||||
if (context === undefined) {
|
||||
context = getContext(this);
|
||||
}
|
||||
if (context === undefined) {
|
||||
this.isLoading = false;
|
||||
this.statusMsg = '无法获取应用上下文';
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
// 3) 从账号列表里找到该账号
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
const foundAcc = accounts.find((a: DavAccount): boolean => a.id === accId);
|
||||
if (foundAcc === undefined) {
|
||||
this.isLoading = false;
|
||||
this.statusMsg = `账号不存在(id=${accId}),可能已被删除`;
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
this.acc = foundAcc;
|
||||
this.found = true;
|
||||
this.accountName = foundAcc.name;
|
||||
this.serverUrl = foundAcc.serverUrl;
|
||||
this.username = foundAcc.username;
|
||||
LogUtil.write(`编辑账号「${foundAcc.name}」:id=${foundAcc.id},当前已选 ${foundAcc.calendarHrefs.length} 个日历本`);
|
||||
for (let i = 0; i < foundAcc.calendarHrefs.length; i++) {
|
||||
const nm: string = i < foundAcc.calendarNames.length ? foundAcc.calendarNames[i] : '';
|
||||
LogUtil.write(` 已选日历本[${i}]「${nm}」${foundAcc.calendarHrefs[i]}`);
|
||||
}
|
||||
await this.fetchCalendars();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`编辑账号初始化失败: ${e.code} - ${e.message}`);
|
||||
this.isLoading = false;
|
||||
this.statusMsg = `页面初始化失败:${e.message}`;
|
||||
this.statusOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
private encodeBasicAuth(): string {
|
||||
try {
|
||||
return buffer.from(`${this.acc.username}:${this.acc.password}`).toString('base64');
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`Base64 编码失败: ${e.message}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchCalendars(): Promise<void> {
|
||||
const token: string = this.encodeBasicAuth();
|
||||
if (token === '') {
|
||||
this.isLoading = false;
|
||||
this.statusMsg = '凭据编码失败:请在真机或模拟器上运行';
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
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:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
|
||||
'</d:prop></d:propfind>';
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const resp: http.HttpResponse = await httpRequest.request(this.acc.serverUrl, {
|
||||
method: 'PROPFIND' as http.RequestMethod,
|
||||
header: {
|
||||
'Authorization': 'Basic ' + token,
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Depth': '1',
|
||||
'User-Agent': 'SyncCalendar/1.0'
|
||||
},
|
||||
extraData: requestBody,
|
||||
connectTimeout: 10000,
|
||||
readTimeout: 15000
|
||||
});
|
||||
console.info(`编辑账号 PROPFIND 响应码: ${resp.responseCode}`);
|
||||
if (resp.responseCode === 401) {
|
||||
this.isLoading = false;
|
||||
this.statusMsg = '登录已失效,请检查账号密码';
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
if (resp.responseCode !== 207 && resp.responseCode !== 200) {
|
||||
this.isLoading = false;
|
||||
this.statusMsg = `获取日历列表失败,服务器返回:${resp.responseCode}`;
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
const xml: string = resp.result as string;
|
||||
LogUtil.write(`编辑账号 PROPFIND → ${resp.responseCode},响应体 ${xml.length} 字符`);
|
||||
const list: EditCalendarItem[] = this.parseCalendarList(xml);
|
||||
this.isLoading = false;
|
||||
if (list.length === 0) {
|
||||
LogUtil.write('编辑账号:未发现任何日历本');
|
||||
this.statusMsg = '该路径下未发现日历本';
|
||||
this.statusOk = false;
|
||||
} else {
|
||||
// 已勾选的日历本按账号当前配置预选
|
||||
for (const item of list) {
|
||||
item.selected = this.acc.calendarHrefs.includes(item.href);
|
||||
LogUtil.write(`编辑账号发现日历本:「${item.name}」${item.href} 颜色=${item.color === '' ? '(无)' : item.color} 预选=${item.selected}`);
|
||||
}
|
||||
this.calendarList = list;
|
||||
this.updateSelectionState();
|
||||
this.statusMsg = `该账号共 ${list.length} 个日历本,当前已选 ${this.selectedCount} 个`;
|
||||
this.statusOk = true;
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`获取日历列表失败: ${e.code} - ${e.message}`);
|
||||
this.isLoading = false;
|
||||
this.statusMsg = `获取日历列表失败:${e.message}`;
|
||||
this.statusOk = false;
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private 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() : '';
|
||||
}
|
||||
|
||||
private parseCalendarList(xml: string): EditCalendarItem[] {
|
||||
const items: EditCalendarItem[] = [];
|
||||
const originMatch = /https?:\/\/[^/]+/i.exec(this.acc.serverUrl);
|
||||
const origin: string = originMatch !== null ? originMatch[0] : '';
|
||||
const blocks: string[] = xml.split(/<\/[\w-]*:?response>/i);
|
||||
for (const block of blocks) {
|
||||
if (!/<[\w-]*:?response[\s>]/i.test(block)) {
|
||||
continue;
|
||||
}
|
||||
const href: string = this.extractTag(block, 'href');
|
||||
if (href === '') {
|
||||
continue;
|
||||
}
|
||||
const resourcetype: string = this.extractTag(block, 'resourcetype');
|
||||
if (!/calendar/i.test(resourcetype)) {
|
||||
continue;
|
||||
}
|
||||
let name: string = this.extractTag(block, 'displayname');
|
||||
if (name === '') {
|
||||
const segs: string[] = href.split('/').filter((s: string) => s !== '');
|
||||
if (segs.length > 0) {
|
||||
try {
|
||||
name = decodeURIComponent(segs[segs.length - 1]);
|
||||
} catch (err) {
|
||||
name = segs[segs.length - 1];
|
||||
}
|
||||
} else {
|
||||
name = href;
|
||||
}
|
||||
}
|
||||
let color: string = AccountStore.normalizeColor(this.extractTag(block, 'getcolor'));
|
||||
if (color === '') {
|
||||
color = AccountStore.normalizeColor(this.extractTag(block, 'calendar-color'));
|
||||
}
|
||||
const fullHref: string = href.startsWith('http') ? href : origin + href;
|
||||
items.push(new EditCalendarItem(fullHref, name, color));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 保存:更新账号的日历本选择与名称,清理失效数据,触发重新同步 */
|
||||
private async saveSelection(): Promise<void> {
|
||||
if (this.isSaving || !this.found) {
|
||||
return;
|
||||
}
|
||||
if (this.accountName.trim() === '') {
|
||||
this.statusMsg = '账户名不能为空';
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
const selectedItems: EditCalendarItem[] = this.calendarList.filter((c: EditCalendarItem) => c.selected);
|
||||
if (selectedItems.length === 0) {
|
||||
this.statusMsg = '请至少勾选一个日历本';
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
this.isSaving = true;
|
||||
try {
|
||||
const context = this.getUIContext().getHostContext();
|
||||
if (context === undefined) {
|
||||
this.statusMsg = '无法获取应用上下文';
|
||||
this.statusOk = false;
|
||||
this.isSaving = false;
|
||||
return;
|
||||
}
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
const target = accounts.find((a: DavAccount): boolean => a.id === this.acc.id);
|
||||
if (target === undefined) {
|
||||
this.statusMsg = '账号不存在,可能已被删除';
|
||||
this.statusOk = false;
|
||||
this.isSaving = false;
|
||||
return;
|
||||
}
|
||||
target.name = this.accountName.trim();
|
||||
target.calendarHrefs = selectedItems.map((c: EditCalendarItem): string => c.href);
|
||||
target.calendarNames = selectedItems.map((c: EditCalendarItem): string => c.name);
|
||||
target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color);
|
||||
LogUtil.write(`编辑账号保存:「${target.name}」id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
|
||||
await AccountStore.saveAll(context, accounts);
|
||||
// 重选后 calKey(accId_序号)会变化,清理已取消勾选的日历本数据
|
||||
const validKeys: string[] =
|
||||
selectedItems.map((c: EditCalendarItem, i: number): string => `${target.id}_${i}`);
|
||||
await EventDb.pruneAccountEvents(context, target.id, validKeys);
|
||||
AppStorage.setOrCreate<string>('pendingSyncAccountId', target.id);
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: `已保存,同步 ${selectedItems.length} 个日历本` });
|
||||
router.back();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存失败: ${e.code} - ${e.message}`);
|
||||
this.statusMsg = `保存失败:${e.message}`;
|
||||
this.statusOk = false;
|
||||
}
|
||||
this.isSaving = false;
|
||||
}
|
||||
|
||||
private updateSelectionState(): void {
|
||||
const count: number = this.calendarList.filter((c: EditCalendarItem): boolean => c.selected).length;
|
||||
this.selectedCount = count;
|
||||
this.allSelected = this.calendarList.length > 0 && count === this.calendarList.length;
|
||||
}
|
||||
|
||||
private handleItemToggle(item: EditCalendarItem): void {
|
||||
// 选中状态已在 EditCalendarRow 内部直接翻转,这里只刷新计数
|
||||
this.updateSelectionState();
|
||||
}
|
||||
|
||||
private toggleAll(): void {
|
||||
const target: boolean = !this.allSelected;
|
||||
this.calendarList.forEach((c: EditCalendarItem) => {
|
||||
c.selected = target;
|
||||
});
|
||||
this.updateSelectionState();
|
||||
}
|
||||
|
||||
build() {
|
||||
Column({ space: 14 }) {
|
||||
Row({ space: 6 }) {
|
||||
Text('←')
|
||||
.fontSize(18)
|
||||
.fontColor($r('app.color.brand'))
|
||||
Text('返回')
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.brand'))
|
||||
}
|
||||
.width('100%')
|
||||
.onClick(() => {
|
||||
router.back();
|
||||
})
|
||||
|
||||
Column({ space: 6 }) {
|
||||
Text('编辑账号')
|
||||
.fontSize(26)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(this.username !== '' ? `${this.serverUrl} · ${this.username}` : this.serverUrl)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.width('100%')
|
||||
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 4 }) {
|
||||
Text('日历账户名称')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
Text('*')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.error'))
|
||||
}
|
||||
TextInput({ text: this.accountName, placeholder: '例如:我的群晖日历' })
|
||||
.height(46)
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.placeholderColor($r('app.color.text_hint'))
|
||||
.backgroundColor($r('app.color.input_bg'))
|
||||
.borderRadius(10)
|
||||
.border({ width: 1.5, color: $r('app.color.brand') })
|
||||
.padding({ left: 12, right: 12 })
|
||||
.onChange((value: string) => {
|
||||
this.accountName = value;
|
||||
})
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(14)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||||
|
||||
Row({ space: 8 }) {
|
||||
Text(this.calendarList.length > 0
|
||||
? `已选 ${this.selectedCount} / ${this.calendarList.length}` : ' ')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
if (this.calendarList.length > 0) {
|
||||
Button(this.allSelected ? '取消全选' : '全选')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.brand'))
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: $r('app.color.brand'), radius: 14 })
|
||||
.height(30)
|
||||
.padding({ left: 14, right: 14 })
|
||||
.onClick(() => {
|
||||
this.toggleAll();
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Scroll() {
|
||||
Column({ space: 4 }) {
|
||||
if (this.isLoading) {
|
||||
Column({ space: 12 }) {
|
||||
LoadingProgress()
|
||||
.width(36)
|
||||
.height(36)
|
||||
.color($r('app.color.brand'))
|
||||
Text('正在从服务器获取日历列表…')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(32)
|
||||
} else if (this.calendarList.length === 0) {
|
||||
Text(this.statusMsg)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(24)
|
||||
.textAlign(TextAlign.Center)
|
||||
} else {
|
||||
ForEach(this.calendarList, (item: EditCalendarItem) => {
|
||||
EditCalendarRow({
|
||||
item: item,
|
||||
onSelect: (selectedItem: EditCalendarItem): void => {
|
||||
this.handleItemToggle(selectedItem);
|
||||
}
|
||||
})
|
||||
}, (item: EditCalendarItem) => `${item.href}_${item.selected}`)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(8)
|
||||
.constraintSize({ minHeight: '100%' })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.align(Alignment.Top)
|
||||
.borderRadius(16)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.shadow({ radius: 12, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 4 })
|
||||
|
||||
if (this.statusMsg !== '' && !this.isLoading) {
|
||||
Row({ space: 6 }) {
|
||||
Text(this.statusOk ? '✓' : '✕')
|
||||
.fontSize(14)
|
||||
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
|
||||
Text(this.statusMsg)
|
||||
.fontSize(14)
|
||||
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.borderRadius(8)
|
||||
.backgroundColor(this.statusOk ? $r('app.color.success_bg') : $r('app.color.error_bg'))
|
||||
}
|
||||
|
||||
Button() {
|
||||
Text(this.isSaving ? '保存中…' : '保存并同步')
|
||||
.fontSize(17)
|
||||
.fontColor($r('app.color.button_text'))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
}
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.borderRadius(24)
|
||||
.backgroundColor(this.isSaving ? $r('app.color.brand_disabled') : $r('app.color.brand'))
|
||||
.enabled(!this.isSaving && !this.isLoading && this.found)
|
||||
.onClick(() => {
|
||||
this.saveSelection();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding({ left: 24, right: 24, top: 16, bottom: 24 })
|
||||
.backgroundColor($r('app.color.page_bg'))
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
struct EditCalendarRow {
|
||||
@ObjectLink item: EditCalendarItem;
|
||||
onSelect: (item: EditCalendarItem) => void = (selectedItem: EditCalendarItem): void => {};
|
||||
|
||||
build() {
|
||||
Row({ space: 12 }) {
|
||||
if (this.item.color !== '') {
|
||||
Circle()
|
||||
.width(12)
|
||||
.height(12)
|
||||
.fill(this.item.color)
|
||||
}
|
||||
Text(this.item.name)
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Blank()
|
||||
if (this.item.selected) {
|
||||
Text('✓')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.button_text'))
|
||||
.width(24)
|
||||
.height(24)
|
||||
.textAlign(TextAlign.Center)
|
||||
.borderRadius(12)
|
||||
.backgroundColor($r('app.color.brand'))
|
||||
} else {
|
||||
Text('')
|
||||
.width(24)
|
||||
.height(24)
|
||||
.borderRadius(12)
|
||||
.border({ width: 1.5, color: $r('app.color.text_hint') })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 12, right: 12, top: 14, bottom: 14 })
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
// 直接翻转 @ObjectLink 属性,子组件自身即可触发刷新
|
||||
this.item.selected = !this.item.selected;
|
||||
this.onSelect(this.item);
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
// entry/src/main/ets/pages/EventEditPage.ets
|
||||
// 日程编辑页:新建 / 修改 / 删除本地(DAV 或本机)日程
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount, AccountStore, CalSource, BookPalette } from '../common/AccountStore';
|
||||
import { EventDb, LocalEvent } from '../common/EventDb';
|
||||
import { DavClient } from '../common/DavClient';
|
||||
import { SyncEngine } from '../common/SyncEngine';
|
||||
|
||||
/** 可选的日历本 */
|
||||
class BookChoice {
|
||||
calKey: string = '';
|
||||
href: string = '';
|
||||
name: string = '';
|
||||
color: string = '#007DFF';
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct EventEditPage {
|
||||
@State title: string = '';
|
||||
@State location: string = '';
|
||||
@State description: string = '';
|
||||
@State allDay: boolean = false;
|
||||
@State startMs: number = 0;
|
||||
@State endMs: number = 0;
|
||||
@State books: BookChoice[] = [];
|
||||
@State chosenKey: string = '';
|
||||
@State isSaving: boolean = false;
|
||||
@State statusMsg: string = '';
|
||||
@State isExisting: boolean = false;
|
||||
private event: LocalEvent | null = null;
|
||||
|
||||
aboutToAppear(): Promise<void> {
|
||||
return this.initPage();
|
||||
}
|
||||
|
||||
private async initPage(): Promise<void> {
|
||||
const context = this.getUIContext().getHostContext();
|
||||
if (context === undefined) {
|
||||
return;
|
||||
}
|
||||
// 收集可写入的日历本(DAV + 本机)
|
||||
const sources: CalSourceWithHref[] = await CalendarDataBridge.loadWritableSources(context);
|
||||
const choices: BookChoice[] = [];
|
||||
for (const s of sources) {
|
||||
const b = new BookChoice();
|
||||
b.calKey = s.calKey;
|
||||
b.href = s.href;
|
||||
b.name = s.name;
|
||||
b.color = s.color;
|
||||
choices.push(b);
|
||||
}
|
||||
this.books = choices;
|
||||
|
||||
// 编辑既有事件
|
||||
const pendingId: number | undefined = AppStorage.get<number>('pendingEventId');
|
||||
if (pendingId !== undefined && pendingId > 0) {
|
||||
const loaded = await EventDb.getById(context, pendingId);
|
||||
if (loaded !== null) {
|
||||
this.event = loaded;
|
||||
this.isExisting = true;
|
||||
this.title = loaded.title;
|
||||
this.location = loaded.location;
|
||||
this.description = loaded.description;
|
||||
this.allDay = loaded.isAllDay;
|
||||
this.startMs = loaded.startTime;
|
||||
this.endMs = loaded.endTime;
|
||||
this.chosenKey = loaded.calKey;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 新建:默认时间 = 所选日期 9:00-10:00
|
||||
const base: number = AppStorage.get<number>('pendingEventDate') ?? Date.now();
|
||||
const dayStart = new Date(new Date(base).getFullYear(), new Date(base).getMonth(),
|
||||
new Date(base).getDate()).getTime();
|
||||
this.startMs = dayStart + 9 * 3600000;
|
||||
this.endMs = dayStart + 10 * 3600000;
|
||||
if (choices.length > 0) {
|
||||
this.chosenKey = choices[0].calKey;
|
||||
}
|
||||
}
|
||||
|
||||
private chosenBook(): BookChoice | null {
|
||||
return this.books.find((b: BookChoice): boolean => b.calKey === this.chosenKey) ?? null;
|
||||
}
|
||||
|
||||
private fmtDate(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const p = (n: number): string => n < 10 ? '0' + n : String(n);
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
||||
}
|
||||
|
||||
private fmtTime(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const p = (n: number): string => n < 10 ? '0' + n : String(n);
|
||||
return `${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
private pickStartDate(): void {
|
||||
const cur = new Date(this.startMs);
|
||||
DatePickerDialog.show({
|
||||
start: new Date(2000, 0, 1),
|
||||
end: new Date(2050, 11, 31),
|
||||
selected: cur,
|
||||
onDateAccept: (value: Date) => {
|
||||
const keep = new Date(this.startMs);
|
||||
const newStart: number = new Date(value.getFullYear(), value.getMonth(), value.getDate(),
|
||||
keep.getHours(), keep.getMinutes()).getTime();
|
||||
const dur: number = this.endMs - this.startMs;
|
||||
this.startMs = newStart;
|
||||
this.endMs = this.allDay ? newStart + 86399999 : newStart + dur;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private pickStartTime(): void {
|
||||
const cur = new Date(this.startMs);
|
||||
TimePickerDialog.show({
|
||||
selected: cur,
|
||||
onAccept: (value: TimePickerResult) => {
|
||||
const d = new Date(this.startMs);
|
||||
const newStart: number = new Date(d.getFullYear(), d.getMonth(), d.getDate(),
|
||||
value.hour, value.minute).getTime();
|
||||
const dur: number = this.endMs - this.startMs;
|
||||
this.startMs = newStart;
|
||||
this.endMs = newStart + dur;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private pickEndDate(): void {
|
||||
const cur = new Date(this.endMs);
|
||||
DatePickerDialog.show({
|
||||
start: new Date(2000, 0, 1),
|
||||
end: new Date(2050, 11, 31),
|
||||
selected: cur,
|
||||
onDateAccept: (value: Date) => {
|
||||
const keep = new Date(this.endMs);
|
||||
this.endMs = new Date(value.getFullYear(), value.getMonth(), value.getDate(),
|
||||
keep.getHours(), keep.getMinutes()).getTime();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private pickEndTime(): void {
|
||||
const cur = new Date(this.endMs);
|
||||
TimePickerDialog.show({
|
||||
selected: cur,
|
||||
onAccept: (value: TimePickerResult) => {
|
||||
const d = new Date(this.endMs);
|
||||
this.endMs = new Date(d.getFullYear(), d.getMonth(), d.getDate(),
|
||||
value.hour, value.minute).getTime();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private toggleAllDay(): void {
|
||||
this.allDay = !this.allDay;
|
||||
if (this.allDay) {
|
||||
const s = new Date(this.startMs);
|
||||
const dayStart: number = new Date(s.getFullYear(), s.getMonth(), s.getDate()).getTime();
|
||||
this.startMs = dayStart;
|
||||
this.endMs = dayStart + 86399999;
|
||||
} else {
|
||||
const s = new Date(this.startMs);
|
||||
this.startMs = s.getTime() + 9 * 3600000;
|
||||
this.endMs = this.startMs + 3600000;
|
||||
}
|
||||
}
|
||||
|
||||
private validate(): boolean {
|
||||
if (this.title.trim() === '') {
|
||||
this.statusMsg = '请输入日程标题';
|
||||
return false;
|
||||
}
|
||||
if (this.endMs < this.startMs) {
|
||||
this.statusMsg = '结束时间不能早于开始时间';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
if (this.isSaving || !this.validate()) {
|
||||
return;
|
||||
}
|
||||
if (this.event !== null && this.event.recurring) {
|
||||
this.statusMsg = '重复日程(系列中的一天)暂不支持修改,请到服务器端调整重复规则';
|
||||
return;
|
||||
}
|
||||
this.isSaving = true;
|
||||
this.statusMsg = '';
|
||||
try {
|
||||
const context = this.getUIContext().getHostContext();
|
||||
if (context === undefined) {
|
||||
return;
|
||||
}
|
||||
const book = this.chosenBook();
|
||||
const e = this.event ?? new LocalEvent();
|
||||
const isNew: boolean = this.event === null;
|
||||
e.title = this.title.trim();
|
||||
e.location = this.location.trim();
|
||||
e.description = this.description.trim();
|
||||
e.startTime = this.startMs;
|
||||
e.endTime = this.allDay ? this.startMs + 86399999 : this.endMs;
|
||||
e.isAllDay = this.allDay;
|
||||
e.calKey = book !== null ? book.calKey : 'local';
|
||||
e.href = book !== null ? book.href : '';
|
||||
if (isNew) {
|
||||
e.uid = `syncal-${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
|
||||
e.remotePath = encodeURIComponent(e.uid) + '.ics';
|
||||
await EventDb.insertLocal(context, e);
|
||||
} else {
|
||||
await EventDb.updateLocal(context, e);
|
||||
}
|
||||
// 立即推送(尽力而为,失败不打断,下次同步会再推)
|
||||
if (e.href !== '') {
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
const acc = accounts.find((a: DavAccount): boolean => a.calendarHrefs.includes(e.href));
|
||||
if (acc !== undefined) {
|
||||
const auth: string = DavClient.authHeader(acc.username, acc.password);
|
||||
await SyncEngine.pushDirtyForAccount(context, acc, auth);
|
||||
}
|
||||
} else {
|
||||
await SyncEngine.settleLocalEvents(context);
|
||||
}
|
||||
this.getUIContext().getPromptAction().showToast({ message: '日程已保存' });
|
||||
router.back();
|
||||
} catch (err) {
|
||||
const ex = err as BusinessError;
|
||||
console.error(`保存日程失败: ${ex.message}`);
|
||||
this.statusMsg = `保存失败:${ex.message}(已保存到本地,稍后同步会重试)`;
|
||||
// 数据仍在本地且带 dirty 标记,不会丢
|
||||
router.back();
|
||||
}
|
||||
this.isSaving = false;
|
||||
}
|
||||
|
||||
private async removeEvent(): Promise<void> {
|
||||
if (this.event === null || this.isSaving) {
|
||||
return;
|
||||
}
|
||||
if (this.event.recurring) {
|
||||
this.statusMsg = '重复日程(系列中的一天)暂不支持删除,请到服务器端调整重复规则';
|
||||
return;
|
||||
}
|
||||
this.isSaving = true;
|
||||
try {
|
||||
const context = this.getUIContext().getHostContext();
|
||||
if (context === undefined) {
|
||||
return;
|
||||
}
|
||||
await EventDb.markDeleted(context, this.event.id);
|
||||
if (this.event.href !== '') {
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
const acc = accounts.find((a: DavAccount): boolean => a.calendarHrefs.includes(this.event?.href ?? ''));
|
||||
if (acc !== undefined) {
|
||||
const auth: string = DavClient.authHeader(acc.username, acc.password);
|
||||
await SyncEngine.pushDirtyForAccount(context, acc, auth);
|
||||
}
|
||||
} else {
|
||||
await SyncEngine.settleLocalEvents(context);
|
||||
}
|
||||
this.getUIContext().getPromptAction().showToast({ message: '日程已删除' });
|
||||
router.back();
|
||||
} catch (err) {
|
||||
const ex = err as BusinessError;
|
||||
this.statusMsg = `删除失败:${ex.message}`;
|
||||
}
|
||||
this.isSaving = false;
|
||||
}
|
||||
|
||||
build() {
|
||||
Column({ space: 14 }) {
|
||||
// 顶部
|
||||
Row({ space: 6 }) {
|
||||
Text('取消')
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.brand'))
|
||||
.onClick(() => {
|
||||
router.back();
|
||||
})
|
||||
Blank()
|
||||
Text(this.isExisting ? '编辑日程' : '新建日程')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text('保存')
|
||||
.fontSize(16)
|
||||
.fontColor(this.isSaving ? $r('app.color.text_hint') : $r('app.color.brand'))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.onClick(() => {
|
||||
this.save();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Scroll() {
|
||||
Column({ space: 14 }) {
|
||||
// 标题
|
||||
TextInput({ text: this.title, placeholder: '标题' })
|
||||
.height(46)
|
||||
.fontSize(16)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.borderRadius(12)
|
||||
.onChange((v: string) => {
|
||||
this.title = v;
|
||||
})
|
||||
|
||||
// 全天
|
||||
Row() {
|
||||
Text('全天')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Toggle({ type: ToggleType.Switch, isOn: this.allDay })
|
||||
.selectedColor($r('app.color.brand'))
|
||||
.onChange(() => {
|
||||
this.toggleAllDay();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(12)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
|
||||
// 时间卡片
|
||||
Column({ space: 10 }) {
|
||||
this.timeRow('开始', true)
|
||||
Divider().color($r('app.color.shadow_color'))
|
||||
this.timeRow('结束', false)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(6)
|
||||
.borderRadius(12)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
|
||||
// 日历本选择
|
||||
Column({ space: 8 }) {
|
||||
Text('日历本')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
|
||||
ForEach(this.books, (b: BookChoice) => {
|
||||
Row({ space: 5 }) {
|
||||
Circle().width(8).height(8).fill(b.color)
|
||||
Text(b.name)
|
||||
.fontSize(12)
|
||||
.fontColor(this.chosenKey === b.calKey
|
||||
? $r('app.color.button_text') : $r('app.color.text_primary'))
|
||||
}
|
||||
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
||||
.borderRadius(14)
|
||||
.margin({ right: 8, bottom: 8 })
|
||||
.backgroundColor(this.chosenKey === b.calKey ? b.color : $r('app.color.chip_off_bg'))
|
||||
.onClick(() => {
|
||||
this.chosenKey = b.calKey;
|
||||
})
|
||||
}, (b: BookChoice) => b.calKey)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(12)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
|
||||
// 地点
|
||||
TextInput({ text: this.location, placeholder: '地点(可选)' })
|
||||
.height(44)
|
||||
.fontSize(14)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.borderRadius(12)
|
||||
.onChange((v: string) => {
|
||||
this.location = v;
|
||||
})
|
||||
|
||||
// 描述
|
||||
TextArea({ text: this.description, placeholder: '备注(可选)' })
|
||||
.height(90)
|
||||
.fontSize(14)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.borderRadius(12)
|
||||
.onChange((v: string) => {
|
||||
this.description = v;
|
||||
})
|
||||
|
||||
if (this.statusMsg !== '') {
|
||||
Text(this.statusMsg)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.error'))
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
// 删除
|
||||
if (this.isExisting) {
|
||||
Button('删除日程')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.error'))
|
||||
.backgroundColor($r('app.color.error_bg'))
|
||||
.width('100%')
|
||||
.height(44)
|
||||
.borderRadius(12)
|
||||
.enabled(!this.isSaving)
|
||||
.onClick(() => {
|
||||
this.removeEvent();
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 20, right: 20, bottom: 30 })
|
||||
.constraintSize({ minHeight: '100%' })
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Off)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.align(Alignment.Top)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding({ top: 12 })
|
||||
.backgroundColor($r('app.color.page_bg'))
|
||||
}
|
||||
|
||||
@Builder
|
||||
timeRow(label: string, isStart: boolean) {
|
||||
Row({ space: 8 }) {
|
||||
Text(label)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(36)
|
||||
Text(this.fmtDate(isStart ? this.startMs : this.endMs))
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.brand'))
|
||||
.onClick(() => {
|
||||
if (isStart) {
|
||||
this.pickStartDate();
|
||||
} else {
|
||||
this.pickEndDate();
|
||||
}
|
||||
})
|
||||
if (!this.allDay) {
|
||||
Text(this.fmtTime(isStart ? this.startMs : this.endMs))
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.brand'))
|
||||
.onClick(() => {
|
||||
if (isStart) {
|
||||
this.pickStartTime();
|
||||
} else {
|
||||
this.pickEndTime();
|
||||
}
|
||||
})
|
||||
}
|
||||
Blank()
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 8, right: 8, top: 8, bottom: 8 })
|
||||
}
|
||||
}
|
||||
|
||||
/** 桥接:从账号存储拿可写来源(DAV 日历本 + 本机),附上 href */
|
||||
class CalendarDataBridge {
|
||||
static async loadWritableSources(context: common.Context): Promise<CalSourceWithHref[]> {
|
||||
const result: CalSourceWithHref[] = [];
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
for (const acc of accounts) {
|
||||
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
||||
const s = new CalSourceWithHref();
|
||||
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.href = acc.calendarHrefs[i];
|
||||
result.push(s);
|
||||
}
|
||||
}
|
||||
const local = new CalSourceWithHref();
|
||||
local.calKey = 'local';
|
||||
local.name = '本机(不同步)';
|
||||
local.color = '#5A6068';
|
||||
result.push(local);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class CalSourceWithHref extends CalSource {
|
||||
href: string = '';
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,149 @@
|
||||
// entry/src/main/ets/pages/SettingsPage.ets
|
||||
// 设置页:系统日历混合显示开关 + 自动同步间隔
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { AppSettings } from '../common/AppSettings';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60];
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct SettingsPage {
|
||||
@State showSystem: boolean = true;
|
||||
@State intervalMinutes: number = 1;
|
||||
private context?: common.Context;
|
||||
|
||||
aboutToAppear(): void {
|
||||
const ctx = this.getUIContext().getHostContext();
|
||||
if (ctx === undefined) {
|
||||
return;
|
||||
}
|
||||
this.context = ctx;
|
||||
LogUtil.init(ctx);
|
||||
AppSettings.getShowSystemCalendar(ctx).then((v: boolean): void => {
|
||||
this.showSystem = v;
|
||||
});
|
||||
AppSettings.getSyncIntervalMinutes(ctx).then((v: number): void => {
|
||||
this.intervalMinutes = v;
|
||||
});
|
||||
}
|
||||
|
||||
private async saveShowSystem(value: boolean): Promise<void> {
|
||||
if (this.context === undefined) {
|
||||
return;
|
||||
}
|
||||
await AppSettings.setShowSystemCalendar(this.context, value);
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: value ? '已开启系统日历混合显示,返回首页生效' : '已关闭系统日历混合显示,返回首页生效' });
|
||||
}
|
||||
|
||||
private async saveInterval(minutes: number): Promise<void> {
|
||||
if (this.context === undefined) {
|
||||
return;
|
||||
}
|
||||
await AppSettings.setSyncIntervalMinutes(this.context, minutes);
|
||||
AppStorage.setOrCreate('syncIntervalMinutes', minutes);
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: `自动同步间隔已设为 ${minutes} 分钟` });
|
||||
}
|
||||
|
||||
private intervalLabel(minutes: number): string {
|
||||
return minutes >= 60 ? `${minutes / 60} 小时` : `${minutes} 分钟`;
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
// 顶部
|
||||
Row({ space: 10 }) {
|
||||
Text('←')
|
||||
.fontSize(20)
|
||||
.fontColor($r('app.color.brand'))
|
||||
.onClick(() => {
|
||||
router.back();
|
||||
})
|
||||
Text('设置')
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 20, right: 20, top: 12, bottom: 8 })
|
||||
|
||||
Scroll() {
|
||||
Column({ space: 12 }) {
|
||||
// 系统日历混合显示
|
||||
Row({ space: 10 }) {
|
||||
Column({ space: 2 }) {
|
||||
Text('混合显示系统日历')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text('关闭后只显示 DAV 账号的日程')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
Toggle({ type: ToggleType.Switch, isOn: this.showSystem })
|
||||
.selectedColor($r('app.color.brand'))
|
||||
.onChange((isOn: boolean) => {
|
||||
if (isOn !== this.showSystem) {
|
||||
this.showSystem = isOn;
|
||||
this.saveShowSystem(isOn);
|
||||
}
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(12)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.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('应用打开期间按此间隔自动同步')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
Select([{ value: '1 分钟' }, { value: '5 分钟' }, { value: '15 分钟' },
|
||||
{ value: '30 分钟' }, { value: '1 小时' }] as SelectOption[])
|
||||
.selected(INTERVAL_OPTIONS.indexOf(this.intervalMinutes))
|
||||
.value(this.intervalLabel(this.intervalMinutes))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.font({ size: 14 })
|
||||
.optionFont({ size: 14 })
|
||||
.selectedOptionFont({ size: 14 })
|
||||
.onSelect((index: number) => {
|
||||
if (index >= 0 && index < INTERVAL_OPTIONS.length) {
|
||||
this.intervalMinutes = INTERVAL_OPTIONS[index];
|
||||
this.saveInterval(INTERVAL_OPTIONS[index]);
|
||||
}
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(12)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 20, right: 20, top: 8, bottom: 20 })
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.align(Alignment.Top)
|
||||
.scrollBar(BarState.Off)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor($r('app.color.page_bg'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// entry/src/main/ets/pages/widget/Widget2x2.ets
|
||||
// 2x2 服务卡片:日期 + 农历 + 下一条日程
|
||||
let storage2x2 = new LocalStorage();
|
||||
|
||||
class CardItem2x2 {
|
||||
title: string = '';
|
||||
time: string = '';
|
||||
endTime: string = '';
|
||||
date: string = '';
|
||||
showDate: boolean = false;
|
||||
calName: string = '';
|
||||
color: string = '#007DFF';
|
||||
}
|
||||
|
||||
@Entry(storage2x2)
|
||||
@Component
|
||||
struct Widget2x2Card {
|
||||
@LocalStorageProp('eventsJson') eventsJson: string = '[]';
|
||||
@LocalStorageProp('dateText') dateText: string = '';
|
||||
@LocalStorageProp('lunarText') lunarText: string = '';
|
||||
|
||||
private parseItems(): CardItem2x2[] {
|
||||
try {
|
||||
return JSON.parse(this.eventsJson) as CardItem2x2[];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Column({ space: 4 }) {
|
||||
Row({ space: 6 }) {
|
||||
Text(this.dateText)
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#1A1A1A')
|
||||
.maxLines(1)
|
||||
Blank()
|
||||
Text(this.lunarText)
|
||||
.fontSize(11)
|
||||
.fontColor('#8A8A8A')
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Divider()
|
||||
.strokeWidth(0.5)
|
||||
.color('#E5E5E5')
|
||||
|
||||
if (this.parseItems().length === 0) {
|
||||
Column({ space: 4 }) {
|
||||
Text('暂无日程')
|
||||
.fontSize(13)
|
||||
.fontColor('#8A8A8A')
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
} else {
|
||||
Column({ space: 4 }) {
|
||||
Row({ space: 6 }) {
|
||||
Circle().width(6).height(6).fill(this.parseItems()[0].color)
|
||||
Text(this.parseItems()[0].title)
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#1A1A1A')
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
if (this.parseItems()[0].calName !== '') {
|
||||
Text(this.parseItems()[0].calName)
|
||||
.fontSize(9)
|
||||
.fontColor(this.parseItems()[0].color)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.constraintSize({ maxWidth: '30%' })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
Blank()
|
||||
Row({ space: 6 }) {
|
||||
Text(this.parseItems()[0].date)
|
||||
.fontSize(10)
|
||||
.fontColor('#8A8A8A')
|
||||
Text(this.parseItems()[0].time === '全天'
|
||||
? '全天'
|
||||
: `${this.parseItems()[0].time} - ${this.parseItems()[0].endTime}`)
|
||||
.fontSize(11)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#007DFF')
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.End)
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding(12)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.borderRadius(16)
|
||||
.onClick(() => {
|
||||
postCardAction(this, {
|
||||
action: 'router',
|
||||
abilityName: 'EntryAbility',
|
||||
params: {}
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// entry/src/main/ets/pages/widget/Widget4x2.ets
|
||||
// 2x4 服务卡片:日期 + 农历 + 未来几条日程(时间轴样式,按天分组)
|
||||
let storage2x4 = new LocalStorage();
|
||||
|
||||
class CardItem2x4 {
|
||||
title: string = '';
|
||||
time: string = '';
|
||||
endTime: string = '';
|
||||
date: string = '';
|
||||
showDate: boolean = false;
|
||||
calName: string = '';
|
||||
color: string = '#007DFF';
|
||||
}
|
||||
|
||||
@Entry(storage2x4)
|
||||
@Component
|
||||
struct Widget4x2Card {
|
||||
@LocalStorageProp('eventsJson') eventsJson: string = '[]';
|
||||
@LocalStorageProp('dateText') dateText: string = '';
|
||||
@LocalStorageProp('lunarText') lunarText: string = '';
|
||||
|
||||
private parseItems(): CardItem2x4[] {
|
||||
try {
|
||||
const all: CardItem2x4[] = JSON.parse(this.eventsJson) as CardItem2x4[];
|
||||
return all.slice(0, 4);
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 全天事件行:圆角矩形 + 左侧日历色竖条 + 标题 + “全天”标记(无时间轴) */
|
||||
@Builder
|
||||
buildAllDayRow(item: CardItem2x4) {
|
||||
Row({ space: 8 }) {
|
||||
Column()
|
||||
.width(3)
|
||||
.height(16)
|
||||
.borderRadius(2)
|
||||
.backgroundColor(item.color)
|
||||
Text(item.title)
|
||||
.fontSize(12)
|
||||
.fontColor('#1A1A1A')
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
Text('全天')
|
||||
.fontSize(9)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor(item.color)
|
||||
.borderRadius(6)
|
||||
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
|
||||
if (item.calName !== '') {
|
||||
Text(item.calName)
|
||||
.fontSize(9)
|
||||
.fontColor(item.color)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.constraintSize({ maxWidth: '25%' })
|
||||
}
|
||||
}
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.width('100%')
|
||||
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
|
||||
.borderRadius(8)
|
||||
.backgroundColor('#F5F7FA')
|
||||
}
|
||||
|
||||
/** 有时间事件行(时间轴):圆角矩形 + 左色竖条 + 开始时间(上)-竖线-结束时间(下) + 标题 */
|
||||
@Builder
|
||||
buildTimedRow(item: CardItem2x4) {
|
||||
Row({ space: 8 }) {
|
||||
Column()
|
||||
.width(3)
|
||||
.height(36)
|
||||
.borderRadius(2)
|
||||
.backgroundColor(item.color)
|
||||
// 时间列:开始时间在上、结束时间在下、中间竖线连接
|
||||
Column({ space: 2 }) {
|
||||
Text(item.time)
|
||||
.fontSize(10)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#333333')
|
||||
Column()
|
||||
.width(1.5)
|
||||
.layoutWeight(1)
|
||||
.backgroundColor('#D8D8D8')
|
||||
.borderRadius(1)
|
||||
Text(item.endTime)
|
||||
.fontSize(10)
|
||||
.fontColor('#999999')
|
||||
}
|
||||
.width(38)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.height(36)
|
||||
Text(item.title)
|
||||
.fontSize(12)
|
||||
.fontColor('#1A1A1A')
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
if (item.calName !== '') {
|
||||
Text(item.calName)
|
||||
.fontSize(9)
|
||||
.fontColor(item.color)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.constraintSize({ maxWidth: '25%' })
|
||||
}
|
||||
}
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.width('100%')
|
||||
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
|
||||
.borderRadius(8)
|
||||
.backgroundColor('#F5F7FA')
|
||||
}
|
||||
|
||||
build() {
|
||||
Column({ space: 4 }) {
|
||||
Row({ space: 6 }) {
|
||||
Text(this.dateText)
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#1A1A1A')
|
||||
.maxLines(1)
|
||||
Text(this.lunarText)
|
||||
.fontSize(11)
|
||||
.fontColor('#8A8A8A')
|
||||
.maxLines(1)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Divider().strokeWidth(0.5).color('#E5E5E5')
|
||||
|
||||
if (this.parseItems().length === 0) {
|
||||
Column() {
|
||||
Text('暂无日程')
|
||||
.fontSize(13)
|
||||
.fontColor('#8A8A8A')
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
} else {
|
||||
List({ space: 4 }) {
|
||||
ForEach(this.parseItems(), (item: CardItem2x4, idx: number) => {
|
||||
ListItem() {
|
||||
Column({ space: 3 }) {
|
||||
if (item.showDate) {
|
||||
Text(item.date)
|
||||
.fontSize(10)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#666666')
|
||||
.width('100%')
|
||||
}
|
||||
if (item.time === '全天') {
|
||||
this.buildAllDayRow(item)
|
||||
} else {
|
||||
this.buildTimedRow(item)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}, (item: CardItem2x4, idx: number) => `${idx}_${item.title}_${item.time}`)
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Off)
|
||||
.cachedCount(4)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding(12)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.borderRadius(16)
|
||||
.onClick(() => {
|
||||
postCardAction(this, {
|
||||
action: 'router',
|
||||
abilityName: 'EntryAbility',
|
||||
params: {}
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// entry/src/main/ets/pages/widget/Widget4x4.ets
|
||||
// 4x4 服务卡片:日期 + 农历 + 从今天开始的日程(时间轴样式,按天分组,可滑动)
|
||||
let storage4x4 = new LocalStorage();
|
||||
|
||||
class CardItem4x4 {
|
||||
title: string = '';
|
||||
time: string = '';
|
||||
endTime: string = '';
|
||||
date: string = '';
|
||||
showDate: boolean = false;
|
||||
calName: string = '';
|
||||
color: string = '#007DFF';
|
||||
}
|
||||
|
||||
@Entry(storage4x4)
|
||||
@Component
|
||||
struct Widget4x4Card {
|
||||
@LocalStorageProp('eventsJson') eventsJson: string = '[]';
|
||||
@LocalStorageProp('dateText') dateText: string = '';
|
||||
@LocalStorageProp('lunarText') lunarText: string = '';
|
||||
|
||||
private parseItems(): CardItem4x4[] {
|
||||
try {
|
||||
return JSON.parse(this.eventsJson) as CardItem4x4[];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 全天事件行:圆角矩形 + 左侧日历色竖条 + 标题 + “全天”标记(无时间轴) */
|
||||
@Builder
|
||||
buildAllDayRow(item: CardItem4x4) {
|
||||
Row({ space: 8 }) {
|
||||
Column()
|
||||
.width(3)
|
||||
.height(16)
|
||||
.borderRadius(2)
|
||||
.backgroundColor(item.color)
|
||||
Text(item.title)
|
||||
.fontSize(12)
|
||||
.fontColor('#1A1A1A')
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
Text('全天')
|
||||
.fontSize(9)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor(item.color)
|
||||
.borderRadius(6)
|
||||
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
|
||||
if (item.calName !== '') {
|
||||
Text(item.calName)
|
||||
.fontSize(9)
|
||||
.fontColor(item.color)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.constraintSize({ maxWidth: '25%' })
|
||||
}
|
||||
}
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.width('100%')
|
||||
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
|
||||
.borderRadius(8)
|
||||
.backgroundColor('#F5F7FA')
|
||||
}
|
||||
|
||||
/** 有时间事件行(时间轴):圆角矩形 + 左色竖条 + 开始时间(上)-竖线-结束时间(下) + 标题 */
|
||||
@Builder
|
||||
buildTimedRow(item: CardItem4x4) {
|
||||
Row({ space: 8 }) {
|
||||
Column()
|
||||
.width(3)
|
||||
.height(38)
|
||||
.borderRadius(2)
|
||||
.backgroundColor(item.color)
|
||||
// 时间列:开始时间在上、结束时间在下、中间竖线连接
|
||||
Column({ space: 2 }) {
|
||||
Text(item.time)
|
||||
.fontSize(10)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#333333')
|
||||
Column()
|
||||
.width(1.5)
|
||||
.layoutWeight(1)
|
||||
.backgroundColor('#D8D8D8')
|
||||
.borderRadius(1)
|
||||
Text(item.endTime)
|
||||
.fontSize(10)
|
||||
.fontColor('#999999')
|
||||
}
|
||||
.width(38)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.height(38)
|
||||
Text(item.title)
|
||||
.fontSize(12)
|
||||
.fontColor('#1A1A1A')
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
if (item.calName !== '') {
|
||||
Text(item.calName)
|
||||
.fontSize(9)
|
||||
.fontColor(item.color)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.constraintSize({ maxWidth: '25%' })
|
||||
}
|
||||
}
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.width('100%')
|
||||
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
|
||||
.borderRadius(8)
|
||||
.backgroundColor('#F5F7FA')
|
||||
}
|
||||
|
||||
build() {
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 6 }) {
|
||||
Text(this.dateText)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#1A1A1A')
|
||||
Text(this.lunarText)
|
||||
.fontSize(12)
|
||||
.fontColor('#8A8A8A')
|
||||
Blank()
|
||||
Text('同步日历')
|
||||
.fontSize(10)
|
||||
.fontColor('#B0B0B0')
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Divider().strokeWidth(0.5).color('#E5E5E5')
|
||||
|
||||
if (this.parseItems().length === 0) {
|
||||
Column({ space: 6 }) {
|
||||
Text('📅')
|
||||
.fontSize(24)
|
||||
Text('暂无日程')
|
||||
.fontSize(13)
|
||||
.fontColor('#8A8A8A')
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
} else {
|
||||
List({ space: 4 }) {
|
||||
ForEach(this.parseItems(), (item: CardItem4x4, idx: number) => {
|
||||
ListItem() {
|
||||
Column({ space: 3 }) {
|
||||
if (item.showDate) {
|
||||
Text(item.date)
|
||||
.fontSize(10)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#666666')
|
||||
.width('100%')
|
||||
}
|
||||
if (item.time === '全天') {
|
||||
this.buildAllDayRow(item)
|
||||
} else {
|
||||
this.buildTimedRow(item)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}, (item: CardItem4x4, idx: number) => `${idx}_${item.title}_${item.time}`)
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.cachedCount(8)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding(14)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.borderRadius(16)
|
||||
.onClick(() => {
|
||||
postCardAction(this, {
|
||||
action: 'router',
|
||||
abilityName: 'EntryAbility',
|
||||
params: {}
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// entry/src/main/ets/pages/widget/Widget6x4.ets
|
||||
// 6x4 服务卡片:日期 + 农历 + 从今天开始的日程(时间轴样式,比 4x4 显示更多)
|
||||
let storage6x4 = new LocalStorage();
|
||||
|
||||
class CardItem6x4 {
|
||||
title: string = '';
|
||||
time: string = '';
|
||||
endTime: string = '';
|
||||
date: string = '';
|
||||
showDate: boolean = false;
|
||||
calName: string = '';
|
||||
color: string = '#007DFF';
|
||||
}
|
||||
|
||||
@Entry(storage6x4)
|
||||
@Component
|
||||
struct Widget6x4Card {
|
||||
@LocalStorageProp('eventsJson') eventsJson: string = '[]';
|
||||
@LocalStorageProp('dateText') dateText: string = '';
|
||||
@LocalStorageProp('lunarText') lunarText: string = '';
|
||||
|
||||
private parseItems(): CardItem6x4[] {
|
||||
try {
|
||||
return JSON.parse(this.eventsJson) as CardItem6x4[];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 全天事件行:圆角矩形 + 左侧日历色竖条 + 标题 + “全天”标记(无时间轴) */
|
||||
@Builder
|
||||
buildAllDayRow(item: CardItem6x4) {
|
||||
Row({ space: 8 }) {
|
||||
Column()
|
||||
.width(3)
|
||||
.height(16)
|
||||
.borderRadius(2)
|
||||
.backgroundColor(item.color)
|
||||
Text(item.title)
|
||||
.fontSize(12)
|
||||
.fontColor('#1A1A1A')
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
Text('全天')
|
||||
.fontSize(9)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor(item.color)
|
||||
.borderRadius(6)
|
||||
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
|
||||
if (item.calName !== '') {
|
||||
Text(item.calName)
|
||||
.fontSize(9)
|
||||
.fontColor(item.color)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.constraintSize({ maxWidth: '25%' })
|
||||
}
|
||||
}
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.width('100%')
|
||||
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
|
||||
.borderRadius(8)
|
||||
.backgroundColor('#F5F7FA')
|
||||
}
|
||||
|
||||
/** 有时间事件行(时间轴):圆角矩形 + 左色竖条 + 开始时间(上)-竖线-结束时间(下) + 标题 */
|
||||
@Builder
|
||||
buildTimedRow(item: CardItem6x4) {
|
||||
Row({ space: 8 }) {
|
||||
Column()
|
||||
.width(3)
|
||||
.height(38)
|
||||
.borderRadius(2)
|
||||
.backgroundColor(item.color)
|
||||
// 时间列:开始时间在上、结束时间在下、中间竖线连接
|
||||
Column({ space: 2 }) {
|
||||
Text(item.time)
|
||||
.fontSize(10)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#333333')
|
||||
Column()
|
||||
.width(1.5)
|
||||
.layoutWeight(1)
|
||||
.backgroundColor('#D8D8D8')
|
||||
.borderRadius(1)
|
||||
Text(item.endTime)
|
||||
.fontSize(10)
|
||||
.fontColor('#999999')
|
||||
}
|
||||
.width(38)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.height(38)
|
||||
Text(item.title)
|
||||
.fontSize(12)
|
||||
.fontColor('#1A1A1A')
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
if (item.calName !== '') {
|
||||
Text(item.calName)
|
||||
.fontSize(9)
|
||||
.fontColor(item.color)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.constraintSize({ maxWidth: '25%' })
|
||||
}
|
||||
}
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.width('100%')
|
||||
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
|
||||
.borderRadius(8)
|
||||
.backgroundColor('#F5F7FA')
|
||||
}
|
||||
|
||||
build() {
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 6 }) {
|
||||
Text(this.dateText)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#1A1A1A')
|
||||
Text(this.lunarText)
|
||||
.fontSize(12)
|
||||
.fontColor('#8A8A8A')
|
||||
Blank()
|
||||
Text('同步日历')
|
||||
.fontSize(10)
|
||||
.fontColor('#B0B0B0')
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Divider().strokeWidth(0.5).color('#E5E5E5')
|
||||
|
||||
if (this.parseItems().length === 0) {
|
||||
Column({ space: 6 }) {
|
||||
Text('📅')
|
||||
.fontSize(24)
|
||||
Text('暂无日程')
|
||||
.fontSize(13)
|
||||
.fontColor('#8A8A8A')
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
} else {
|
||||
List({ space: 4 }) {
|
||||
ForEach(this.parseItems(), (item: CardItem6x4, idx: number) => {
|
||||
ListItem() {
|
||||
Column({ space: 3 }) {
|
||||
if (item.showDate) {
|
||||
Text(item.date)
|
||||
.fontSize(10)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#666666')
|
||||
.width('100%')
|
||||
}
|
||||
if (item.time === '全天') {
|
||||
this.buildAllDayRow(item)
|
||||
} else {
|
||||
this.buildTimedRow(item)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}, (item: CardItem6x4, idx: number) => `${idx}_${item.title}_${item.time}`)
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.cachedCount(12)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding(14)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.borderRadius(16)
|
||||
.onClick(() => {
|
||||
postCardAction(this, {
|
||||
action: 'router',
|
||||
abilityName: 'EntryAbility',
|
||||
params: {}
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"module": {
|
||||
"name": "entry",
|
||||
"type": "entry",
|
||||
"description": "$string:module_desc",
|
||||
"mainElement": "EntryAbility",
|
||||
"deviceTypes": [
|
||||
"phone"
|
||||
],
|
||||
"requestPermissions": [
|
||||
{
|
||||
"name": "ohos.permission.INTERNET"
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.PUBLISH_AGENT_REMINDER"
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.READ_CALENDAR",
|
||||
"reason": "$string:perm_read_calendar",
|
||||
"usedScene": {
|
||||
"abilities": [
|
||||
"EntryAbility"
|
||||
],
|
||||
"when": "inuse"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.WRITE_CALENDAR",
|
||||
"reason": "$string:perm_write_calendar",
|
||||
"usedScene": {
|
||||
"abilities": [
|
||||
"EntryAbility"
|
||||
],
|
||||
"when": "inuse"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.READ_WHOLE_CALENDAR",
|
||||
"reason": "$string:perm_read_whole_calendar",
|
||||
"usedScene": {
|
||||
"abilities": [
|
||||
"EntryAbility"
|
||||
],
|
||||
"when": "inuse"
|
||||
}
|
||||
}
|
||||
],
|
||||
"deliveryWithInstall": true,
|
||||
"installationFree": false,
|
||||
"pages": "$profile:main_pages",
|
||||
"abilities": [
|
||||
{
|
||||
"name": "EntryAbility",
|
||||
"srcEntry": "./ets/entryability/EntryAbility.ets",
|
||||
"description": "$string:EntryAbility_desc",
|
||||
"icon": "$media:layered_image",
|
||||
"label": "$string:EntryAbility_label",
|
||||
"startWindowIcon": "$media:startIcon",
|
||||
"startWindowBackground": "$color:start_window_background",
|
||||
"exported": true,
|
||||
"skills": [
|
||||
{
|
||||
"entities": [
|
||||
"entity.system.home"
|
||||
],
|
||||
"actions": [
|
||||
"ohos.want.action.home"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"extensionAbilities": [
|
||||
{
|
||||
"name": "EntryBackupAbility",
|
||||
"srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets",
|
||||
"type": "backup",
|
||||
"exported": false,
|
||||
"metadata": [
|
||||
{
|
||||
"name": "ohos.extension.backup",
|
||||
"resource": "$profile:backup_config"
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "EntryFormAbility",
|
||||
"srcEntry": "./ets/entryformability/EntryFormAbility.ets",
|
||||
"description": "$string:card_ability_desc",
|
||||
"type": "form",
|
||||
"exported": true,
|
||||
"metadata": [
|
||||
{
|
||||
"name": "ohos.extension.form",
|
||||
"resource": "$profile:form_config"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"color": [
|
||||
{ "name": "start_window_background", "value": "#FFFFFF" },
|
||||
{ "name": "page_bg", "value": "#F1F3F5" },
|
||||
{ "name": "card_bg", "value": "#FFFFFF" },
|
||||
{ "name": "input_bg", "value": "#F1F3F5" },
|
||||
{ "name": "text_primary", "value": "#182431" },
|
||||
{ "name": "text_secondary", "value": "#66000000" },
|
||||
{ "name": "text_hint", "value": "#4D000000" },
|
||||
{ "name": "brand", "value": "#007DFF" },
|
||||
{ "name": "brand_disabled", "value": "#99007DFF" },
|
||||
{ "name": "button_text", "value": "#FFFFFF" },
|
||||
{ "name": "success", "value": "#00B34A" },
|
||||
{ "name": "success_bg", "value": "#0A00B34A" },
|
||||
{ "name": "error", "value": "#E02020" },
|
||||
{ "name": "error_bg", "value": "#0AE02020" },
|
||||
{ "name": "shadow_color", "value": "#1A000000" },
|
||||
{ "name": "grid_line", "value": "#14000000" },
|
||||
{ "name": "today_bg", "value": "#14007DFF" },
|
||||
{ "name": "selected_bg", "value": "#007DFF" },
|
||||
{ "name": "chip_off_bg", "value": "#0D000000" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"float": [
|
||||
{
|
||||
"name": "page_text_font_size",
|
||||
"value": "50fp"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"string": [
|
||||
{
|
||||
"name": "module_desc",
|
||||
"value": "同步日历:以同步为核心的日历应用"
|
||||
},
|
||||
{
|
||||
"name": "EntryAbility_desc",
|
||||
"value": "同步日历主界面"
|
||||
},
|
||||
{
|
||||
"name": "EntryAbility_label",
|
||||
"value": "同步日历"
|
||||
},
|
||||
{
|
||||
"name": "perm_read_calendar",
|
||||
"value": "读取系统日历日程,用于在日历视图中混合展示"
|
||||
},
|
||||
{
|
||||
"name": "perm_write_calendar",
|
||||
"value": "写入系统日历,用于保存您创建的日程"
|
||||
},
|
||||
{
|
||||
"name": "perm_read_whole_calendar",
|
||||
"value": "读取所有日历账户的日程,用于在日历中统一展示"
|
||||
},
|
||||
{
|
||||
"name": "card_ability_desc",
|
||||
"value": "同步日历服务卡片"
|
||||
},
|
||||
{
|
||||
"name": "card_desc",
|
||||
"value": "展示从今天开始的日程安排"
|
||||
},
|
||||
{
|
||||
"name": "card_2x2_name",
|
||||
"value": "下一条日程"
|
||||
},
|
||||
{
|
||||
"name": "card_2x4_name",
|
||||
"value": "日程速览"
|
||||
},
|
||||
{
|
||||
"name": "card_4x4_name",
|
||||
"value": "日程列表"
|
||||
},
|
||||
{
|
||||
"name": "card_6x4_name",
|
||||
"value": "日程大全"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 382 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 382 KiB |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"layered-image":
|
||||
{
|
||||
"background" : "$media:background",
|
||||
"foreground" : "$media:foreground"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 382 KiB |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"allowToBackupRestore": true
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"forms": [
|
||||
{
|
||||
"name": "WidgetCard2x2",
|
||||
"displayName": "$string:card_2x2_name",
|
||||
"description": "$string:card_desc",
|
||||
"src": "./ets/pages/widget/Widget2x2.ets",
|
||||
"window": {
|
||||
"designWidth": 720,
|
||||
"autoDesignWidth": true
|
||||
},
|
||||
"colorMode": "light",
|
||||
"uiSyntax": "arkts",
|
||||
"isDefault": true,
|
||||
"updateEnabled": true,
|
||||
"scheduledUpdateTime": "07:30",
|
||||
"updateDuration": 1,
|
||||
"defaultDimension": "2*2",
|
||||
"supportDimensions": ["2*2"]
|
||||
},
|
||||
{
|
||||
"name": "WidgetCard2x4",
|
||||
"displayName": "$string:card_2x4_name",
|
||||
"description": "$string:card_desc",
|
||||
"src": "./ets/pages/widget/Widget4x2.ets",
|
||||
"window": {
|
||||
"designWidth": 720,
|
||||
"autoDesignWidth": true
|
||||
},
|
||||
"colorMode": "light",
|
||||
"uiSyntax": "arkts",
|
||||
"isDefault": false,
|
||||
"updateEnabled": true,
|
||||
"scheduledUpdateTime": "07:30",
|
||||
"updateDuration": 1,
|
||||
"defaultDimension": "2*4",
|
||||
"supportDimensions": ["2*4"]
|
||||
},
|
||||
{
|
||||
"name": "WidgetCard4x4",
|
||||
"displayName": "$string:card_4x4_name",
|
||||
"description": "$string:card_desc",
|
||||
"src": "./ets/pages/widget/Widget4x4.ets",
|
||||
"window": {
|
||||
"designWidth": 720,
|
||||
"autoDesignWidth": true
|
||||
},
|
||||
"colorMode": "light",
|
||||
"uiSyntax": "arkts",
|
||||
"isDefault": false,
|
||||
"updateEnabled": true,
|
||||
"scheduledUpdateTime": "07:30",
|
||||
"updateDuration": 1,
|
||||
"defaultDimension": "4*4",
|
||||
"supportDimensions": ["4*4"]
|
||||
},
|
||||
{
|
||||
"name": "WidgetCard6x4",
|
||||
"displayName": "$string:card_6x4_name",
|
||||
"description": "$string:card_desc",
|
||||
"src": "./ets/pages/widget/Widget6x4.ets",
|
||||
"window": {
|
||||
"designWidth": 720,
|
||||
"autoDesignWidth": true
|
||||
},
|
||||
"colorMode": "light",
|
||||
"uiSyntax": "arkts",
|
||||
"isDefault": false,
|
||||
"updateEnabled": true,
|
||||
"scheduledUpdateTime": "07:30",
|
||||
"updateDuration": 1,
|
||||
"defaultDimension": "6*4",
|
||||
"supportDimensions": ["6*4"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"src": [
|
||||
"pages/Index",
|
||||
"pages/AccountsPage",
|
||||
"pages/AddAccountPage",
|
||||
"pages/CalendarListPage",
|
||||
"pages/EditAccountPage",
|
||||
"pages/EventEditPage",
|
||||
"pages/SettingsPage"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user