commit bbb3ec5334a6dc6025f54a13604203dde1616ecb Author: Yang Yongquan Date: Sun Sep 13 15:50:37 2026 +0800 首次提交:SyncCalendar 项目 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ebca769 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# 编译产物 +/build/ +/entry/build/ +/entry/.preview/ +/.hvigor/ +/oh_modules/ +/entry/oh_modules/ + +# IDE 配置 +/.idea/ +*.iml + +# 本地配置 +/local.properties +/.clangd +/.clang-format +/.clang-tidy \ No newline at end of file diff --git a/AppScope/app.json5 b/AppScope/app.json5 new file mode 100644 index 0000000..5491a79 --- /dev/null +++ b/AppScope/app.json5 @@ -0,0 +1,11 @@ +{ + "app": { + "bundleName": "com.example.synccalendar", + "vendor": "example", + "versionCode": 1000000, + "versionName": "1.0.0", + "buildVersion": "1", + "icon": "$media:layered_image", + "label": "$string:app_name" + } +} \ No newline at end of file diff --git a/AppScope/resources/base/element/string.json b/AppScope/resources/base/element/string.json new file mode 100644 index 0000000..9c9a709 --- /dev/null +++ b/AppScope/resources/base/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "app_name", + "value": "同步日历" + } + ] +} \ No newline at end of file diff --git a/AppScope/resources/base/media/background.png b/AppScope/resources/base/media/background.png new file mode 100644 index 0000000..2c37672 Binary files /dev/null and b/AppScope/resources/base/media/background.png differ diff --git a/AppScope/resources/base/media/foreground.png b/AppScope/resources/base/media/foreground.png new file mode 100644 index 0000000..0642a46 Binary files /dev/null and b/AppScope/resources/base/media/foreground.png differ diff --git a/AppScope/resources/base/media/layered_image.json b/AppScope/resources/base/media/layered_image.json new file mode 100644 index 0000000..fb49920 --- /dev/null +++ b/AppScope/resources/base/media/layered_image.json @@ -0,0 +1,7 @@ +{ + "layered-image": + { + "background" : "$media:background", + "foreground" : "$media:foreground" + } +} \ No newline at end of file diff --git a/build-profile.json5 b/build-profile.json5 new file mode 100644 index 0000000..3665959 --- /dev/null +++ b/build-profile.json5 @@ -0,0 +1,56 @@ +{ + "app": { + "signingConfigs": [ + { + "name": "default", + "type": "HarmonyOS", + "material": { + "certpath": "C:\\Users\\Yongquan\\.ohos\\config\\default_SyncCalendar_0sxf9mFmzO-SlvjrtFdry7WYo0XNlBNQv53r6bYgUXU=.cer", + "keyAlias": "debugKey", + "keyPassword": "0000001B33AD9F6D120B4EECB746F81D96D510F30604A697D2B980246F99EE96912DFC3993DD956CD73E60", + "profile": "C:\\Users\\Yongquan\\.ohos\\config\\default_SyncCalendar_0sxf9mFmzO-SlvjrtFdry7WYo0XNlBNQv53r6bYgUXU=.p7b", + "signAlg": "SHA256withECDSA", + "storeFile": "C:\\Users\\Yongquan\\.ohos\\config\\default_SyncCalendar_0sxf9mFmzO-SlvjrtFdry7WYo0XNlBNQv53r6bYgUXU=.p12", + "storePassword": "0000001B34494825D65DEA8FE982119843372F798BDED6A2A483601114F60B9C3A947B6E08F85DB9F3BA15" + } + } + ], + "products": [ + { + "name": "default", + "signingConfig": "default", + "targetSdkVersion": "26.0.0", + "compatibleSdkVersion": "6.1.1(24)", + "runtimeOS": "HarmonyOS", + "buildOption": { + "strictMode": { + "caseSensitiveCheck": true, + "useNormalizedOHMUrl": true + } + } + } + ], + "buildModeSet": [ + { + "name": "debug", + }, + { + "name": "release" + } + ] + }, + "modules": [ + { + "name": "entry", + "srcPath": "./entry", + "targets": [ + { + "name": "default", + "applyToProducts": [ + "default" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/entry/build-profile.json5 b/entry/build-profile.json5 new file mode 100644 index 0000000..14e1253 --- /dev/null +++ b/entry/build-profile.json5 @@ -0,0 +1,30 @@ +{ + "apiType": "stageMode", + "buildOption": { + "resOptions": { + "copyCodeResource": { + "enable": false + } + } + }, + "buildOptionSet": [ + { + "name": "release", + "arkOptions": { + "obfuscation": { + "ruleOptions": { + "enable": false, + "files": [ + "./obfuscation-rules.txt" + ] + } + } + } + }, + ], + "targets": [ + { + "name": "default" + } + ] +} \ No newline at end of file diff --git a/entry/hvigorfile.ts b/entry/hvigorfile.ts new file mode 100644 index 0000000..56e6672 --- /dev/null +++ b/entry/hvigorfile.ts @@ -0,0 +1,6 @@ +import { hapTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: hapTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins: [] /* Custom plugin to extend the functionality of Hvigor. */ +} \ No newline at end of file diff --git a/entry/obfuscation-rules.txt b/entry/obfuscation-rules.txt new file mode 100644 index 0000000..ea6feb6 --- /dev/null +++ b/entry/obfuscation-rules.txt @@ -0,0 +1,23 @@ +# Define project specific obfuscation rules here. +# You can include the obfuscation configuration files in the current module. +# +# For more details, see +# https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/source-obfuscation + +# Obfuscation options: +# -disable-obfuscation: disable all obfuscations +# -enable-property-obfuscation: obfuscate the property names +# -enable-toplevel-obfuscation: obfuscate the names in the global scope +# -compact: remove unnecessary blank spaces and all line feeds +# -remove-log: remove all console.* statements +# -print-namecache: print the name cache that contains the mapping from the old names to new names +# -apply-namecache: reuse the given cache file + +# Keep options: +# -keep-property-name: specifies property names that you want to keep +# -keep-global-name: specifies names that you want to keep in the global scope + +-enable-property-obfuscation +-enable-toplevel-obfuscation +-enable-filename-obfuscation +-enable-export-obfuscation diff --git a/entry/oh-package.json5 b/entry/oh-package.json5 new file mode 100644 index 0000000..e4baad7 --- /dev/null +++ b/entry/oh-package.json5 @@ -0,0 +1,9 @@ +{ + "name": "entry", + "version": "1.0.0", + "description": "Please describe the basic information.", + "main": "", + "author": "", + "license": "", + "dependencies": {} +} diff --git a/entry/src/main/ets/common/AccountStore.ets b/entry/src/main/ets/common/AccountStore.ets new file mode 100644 index 0000000..6401a80 --- /dev/null +++ b/entry/src/main/ets/common/AccountStore.ets @@ -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 { + 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 { + 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 { + const list: DavAccount[] = await AccountStore.loadAll(context); + list.push(acc); + await AccountStore.saveAll(context, list); + } +} \ No newline at end of file diff --git a/entry/src/main/ets/common/AppSettings.ets b/entry/src/main/ets/common/AppSettings.ets new file mode 100644 index 0000000..e4edb11 --- /dev/null +++ b/entry/src/main/ets/common/AppSettings.ets @@ -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 { + 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 { + 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 { + 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 { + 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}`); + } + } +} diff --git a/entry/src/main/ets/common/CalendarDataService.ets b/entry/src/main/ets/common/CalendarDataService.ets new file mode 100644 index 0000000..d749055 --- /dev/null +++ b/entry/src/main/ets/common/CalendarDataService.ets @@ -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 { + 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 { + 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 { + 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 { + 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; + } +} \ No newline at end of file diff --git a/entry/src/main/ets/common/CardDataService.ets b/entry/src/main/ets/common/CardDataService.ets new file mode 100644 index 0000000..7ba003f --- /dev/null +++ b/entry/src/main/ets/common/CardDataService.ets @@ -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 { + if (formId !== '' && !CardDataService.formIds.includes(formId)) { + CardDataService.formIds.push(formId); + } + await CardDataService.saveFormIds(context); + } + + /** 注销卡片:内存 + 持久化 */ + static async unregisterForm(context: common.Context, formId: string): Promise { + CardDataService.formIds = CardDataService.formIds.filter((id: string): boolean => id !== formId); + await CardDataService.saveFormIds(context); + } + + private static async saveFormIds(context: common.Context): Promise { + 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 { + 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 { + // 先恢复持久化的 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 { + 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(); + 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; + } +} diff --git a/entry/src/main/ets/common/DavClient.ets b/entry/src/main/ets/common/DavClient.ets new file mode 100644 index 0000000..3d2f293 --- /dev/null +++ b/entry/src/main/ets/common/DavClient.ets @@ -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 { + const requestBody: string = '' + + '' + + '' + + ''; + 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 { + return DavClient.reportComponents(href, auth, 'VEVENT', ''); + } + + /** REPORT calendar-query:拉取某日历本内所有 VTODO 待办(不限时间范围,量小) */ + static async reportTodos(href: string, auth: string): Promise { + return DavClient.reportComponents(href, auth, 'VTODO', ''); + } + + /** 通用 REPORT:按组件类型过滤拉取 calendar-data + getetag */ + private static async reportComponents(href: string, auth: string, + compName: string, timeRange: string): Promise { + const body: string = '' + + '' + + '' + + '' + + `` + + timeRange + + ''; + 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 { + 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; + 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 { + 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() : ''; + } +} \ No newline at end of file diff --git a/entry/src/main/ets/common/EventDb.ets b/entry/src/main/ets/common/EventDb.ets new file mode 100644 index 0000000..cc4a78e --- /dev/null +++ b/entry/src/main/ets/common/EventDb.ets @@ -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 = ''; // 集合内资源文件名,如 .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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} \ No newline at end of file diff --git a/entry/src/main/ets/common/IcsUtil.ets b/entry/src/main/ets/common/IcsUtil.ets new file mode 100644 index 0000000..428bc45 --- /dev/null +++ b/entry/src/main/ets/common/IcsUtil.ets @@ -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; + } +} \ No newline at end of file diff --git a/entry/src/main/ets/common/LogUtil.ets b/entry/src/main/ets/common/LogUtil.ets new file mode 100644 index 0000000..5435a4b --- /dev/null +++ b/entry/src/main/ets/common/LogUtil.ets @@ -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 { + } +} diff --git a/entry/src/main/ets/common/LunarUtil.ets b/entry/src/main/ets/common/LunarUtil.ets new file mode 100644 index 0000000..4629cef --- /dev/null +++ b/entry/src/main/ets/common/LunarUtil.ets @@ -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); + } +} diff --git a/entry/src/main/ets/common/ReminderService.ets b/entry/src/main/ets/common/ReminderService.ets new file mode 100644 index 0000000..6b957ff --- /dev/null +++ b/entry/src/main/ets/common/ReminderService.ets @@ -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 { + 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())}`; + } +} diff --git a/entry/src/main/ets/common/RruleUtil.ets b/entry/src/main/ets/common/RruleUtil.ets new file mode 100644 index 0000000..877d23c --- /dev/null +++ b/entry/src/main/ets/common/RruleUtil.ets @@ -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(); + } +} diff --git a/entry/src/main/ets/common/SyncEngine.ets b/entry/src/main/ets/common/SyncEngine.ets new file mode 100644 index 0000000..8939f37 --- /dev/null +++ b/entry/src/main/ets/common/SyncEngine.ets @@ -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(task: Promise, ms: number): Promise { + return Promise.race([ + task, + new Promise((_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 { + const t0: number = Date.now(); + LogUtil.write(`========== 同步账号「${acc.name}」开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`); + try { + const r: number = await SyncEngine.withTimeout( + 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 { + 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 { + 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 { + 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 { + 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, ''); + } + } + } +} \ No newline at end of file diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets new file mode 100644 index 0000000..6d10a42 --- /dev/null +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -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'); + } +} \ No newline at end of file diff --git a/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets b/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets new file mode 100644 index 0000000..8e4de99 --- /dev/null +++ b/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets @@ -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(); + } +} \ No newline at end of file diff --git a/entry/src/main/ets/entryformability/EntryFormAbility.ets b/entry/src/main/ets/entryformability/EntryFormAbility.ets new file mode 100644 index 0000000..09949a4 --- /dev/null +++ b/entry/src/main/ets/entryformability/EntryFormAbility.ets @@ -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); + } +} diff --git a/entry/src/main/ets/pages/AccountsPage.ets b/entry/src/main/ets/pages/AccountsPage.ets new file mode 100644 index 0000000..71fde35 --- /dev/null +++ b/entry/src/main/ets/pages/AccountsPage.ets @@ -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 { + const context = this.getUIContext().getHostContext(); + if (context === undefined) { + return; + } + this.accounts = await AccountStore.loadAll(context); + const pendingId: string | undefined = AppStorage.get('pendingSyncAccountId'); + if (pendingId !== undefined && pendingId !== '') { + AppStorage.setOrCreate('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 { + 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('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('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 { + 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); + })) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/AddAccountPage.ets b/entry/src/main/ets/pages/AddAccountPage.ets new file mode 100644 index 0000000..98a946e --- /dev/null +++ b/entry/src/main/ets/pages/AddAccountPage.ets @@ -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('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 { + 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 { + 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 { + 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('pendingDavUrl', targetUrl); + AppStorage.setOrCreate('pendingDavUsername', this.username.trim()); + AppStorage.setOrCreate('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%') + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/CalendarListPage.ets b/entry/src/main/ets/pages/CalendarListPage.ets new file mode 100644 index 0000000..568879f --- /dev/null +++ b/entry/src/main/ets/pages/CalendarListPage.ets @@ -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 { + return this.initPage(); + } + + private async initPage(): Promise { + const ctx = this.getUIContext().getHostContext(); + if (ctx !== undefined) { + LogUtil.init(ctx); + } + this.serverUrl = AppStorage.get('pendingDavUrl') ?? ''; + this.username = AppStorage.get('pendingDavUsername') ?? ''; + this.password = AppStorage.get('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 { + const token: string = this.encodeBasicAuth(); + if (token === '') { + this.isLoading = false; + this.statusMsg = '凭据编码失败:请在真机或模拟器上运行'; + this.statusOk = false; + return; + } + const requestBody: string = '' + + '' + + '' + + ''; + 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 { + 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('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('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); + }) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/EditAccountPage.ets b/entry/src/main/ets/pages/EditAccountPage.ets new file mode 100644 index 0000000..808ae86 --- /dev/null +++ b/entry/src/main/ets/pages/EditAccountPage.ets @@ -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 { + return this.initPage(); + } + + private async initPage(): Promise { + try { + // 1) 读取目标账号 id(AppStorage 为主,路由参数兜底) + let accId: string | undefined = AppStorage.get('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 { + const token: string = this.encodeBasicAuth(); + if (token === '') { + this.isLoading = false; + this.statusMsg = '凭据编码失败:请在真机或模拟器上运行'; + this.statusOk = false; + return; + } + const requestBody: string = '' + + '' + + '' + + ''; + 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 { + 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('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); + }) + } +} diff --git a/entry/src/main/ets/pages/EventEditPage.ets b/entry/src/main/ets/pages/EventEditPage.ets new file mode 100644 index 0000000..cc6d439 --- /dev/null +++ b/entry/src/main/ets/pages/EventEditPage.ets @@ -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 { + return this.initPage(); + } + + private async initPage(): Promise { + 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('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('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 { + 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 { + 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 { + 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 = ''; +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets new file mode 100644 index 0000000..f6fcff2 --- /dev/null +++ b/entry/src/main/ets/pages/Index.ets @@ -0,0 +1,1113 @@ +// entry/src/main/ets/pages/Index.ets +// 同步日历主界面:月视图(左右滑动翻月)/ 周视图 / 日视图 / 日程列表 +// 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天" +import { router } from '@kit.ArkUI'; +import { common } from '@kit.AbilityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore'; +import { DisplayEvent, CalendarDataService } from '../common/CalendarDataService'; +import { SyncEngine } from '../common/SyncEngine'; +import { LogUtil } from '../common/LogUtil'; +import { LunarUtil } from '../common/LunarUtil'; +import { CardDataService } from '../common/CardDataService'; +import { ReminderService } from '../common/ReminderService'; +import { AppSettings } from '../common/AppSettings'; + +/** 月视图单元格 */ +class MonthCell { + dateMs: number = 0; + day: number = 0; + inMonth: boolean = false; + isToday: boolean = false; + lunar: string = ''; // 农历(初一时显示月名) +} + +const WEEK_LABELS: string[] = ['一', '二', '三', '四', '五', '六', '日']; + +@Entry +@Component +struct Index { + @State mode: string = 'month'; // month | week | day | agenda + @State displayYear: number = 2026; + @State displayMonth: number = 0; // 0-11 + @State selectedDate: number = 0; // 当天 0 点毫秒 + @State events: DisplayEvent[] = []; + @State todos: DisplayEvent[] = []; // 待办(VTODO,单独展示) + @State agendaEvents: DisplayEvent[] = []; // 列表视图全量数据(过去1年~未来2年) + @State agendaGroupsData: AgendaGroup[] = []; // 预计算分组(避免 build 中重算卡顿) + private agendaStale: boolean = true; // 全量数据是否需要重新加载 + @State monthPages: MonthCell[][] = [[], [], []]; // 上月/本月/下月 + @State syncing: boolean = false; + @State loading: boolean = true; + private accounts: DavAccount[] = []; + private sources: CalSource[] = []; + private swiperController: SwiperController = new SwiperController(); + private swiperGuard: boolean = false; + private autoSyncTimer: number = -1; + private lastSyncTime: number = 0; + private permissionAsked: boolean = false; + + aboutToAppear(): void { + const ctx = this.getUIContext().getHostContext(); + if (ctx !== undefined) { + LogUtil.init(ctx); + } + LogUtil.write('---- App 启动:主界面初始化 ----'); + const now = new Date(); + this.displayYear = now.getFullYear(); + this.displayMonth = now.getMonth(); + this.selectedDate = this.startOfDay(now.getTime()); + this.rebuildPages(); + this.initPermissionAndLoad(); + } + + aboutToDisappear(): void { + if (this.autoSyncTimer !== -1) { + clearInterval(this.autoSyncTimer); + this.autoSyncTimer = -1; + } + } + + /** 从设置页/账号页返回时刷新(同步间隔、系统日历开关立即生效),并处理编辑账号后的待同步 */ + onPageShow(): void { + if (this.lastSyncTime > 0 || this.accounts.length > 0) { + this.reloadAll().then((): Promise => this.handlePendingSync()); + } else { + this.handlePendingSync(); + } + } + + private async handlePendingSync(): Promise { + const pendingId: string | undefined = AppStorage.get('pendingSyncAccountId'); + if (pendingId !== undefined && pendingId !== '') { + AppStorage.setOrCreate('pendingSyncAccountId', ''); + await this.syncSingle(pendingId); + } + } + + private initPermissionAndLoad(): void { + const context = this.getUIContext().getHostContext(); + if (context === undefined) { + return; + } + this.reloadAll(); + if (!this.permissionAsked) { + this.permissionAsked = true; + // 申请读取全部日历权限(用于混合展示系统日程) + CalendarDataService.ensureSystemCalendarPermission(context as common.UIAbilityContext) + .then((): void => { + this.reloadAll(); + }); + } + } + + // ---------- 工具 ---------- + private startOfDay(ms: number): number { + const d = new Date(ms); + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + } + + 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 fmtDateCn(ms: number): string { + const d = new Date(ms); + return `${d.getMonth() + 1}月${d.getDate()}日 ${WEEK_LABELS[(d.getDay() + 6) % 7]}`; + } + + private fmtMonthTitle(): string { + return `${this.displayYear}年${this.displayMonth + 1}月`; + } + + private addMonths(y: number, m: number, delta: number): number[] { + let mm: number = m + delta; + let yy: number = y; + while (mm < 0) { + mm += 12; + yy--; + } + while (mm > 11) { + mm -= 12; + yy++; + } + return [yy, mm]; + } + + // ---------- 数据 ---------- + private async reloadAll(): Promise { + const context = this.getUIContext().getHostContext(); + if (context === undefined) { + return; + } + this.accounts = await AccountStore.loadAll(context); + this.sources = await CalendarDataService.loadSources(context); + await this.reloadEvents(); + this.startAutoSync(); + } + + /** 自动同步:每 15 秒检查一次,到达设置的间隔(AppStorage.syncIntervalMinutes)才真正同步 */ + private startAutoSync(): void { + if (this.autoSyncTimer === -1) { + AppStorage.setOrCreate('syncIntervalMinutes', 1); + const ctx = this.getUIContext().getHostContext(); + if (ctx !== undefined) { + AppSettings.getSyncIntervalMinutes(ctx).then((v: number): void => { + AppStorage.setOrCreate('syncIntervalMinutes', v); + }); + } + this.autoSyncTimer = setInterval((): void => { + const minutes: number = AppStorage.get('syncIntervalMinutes') ?? 1; + if (Date.now() - this.lastSyncTime < minutes * 60000) { + return; // 未到设置的同步间隔 + } + if (!this.syncing && this.accounts.length > 0) { + this.lastSyncTime = Date.now(); + this.syncAll(false); + } + }, 15000); + } + } + + private async reloadEvents(): Promise { + const context = this.getUIContext().getHostContext(); + if (context === undefined) { + return; + } + this.loading = true; + this.agendaStale = true; // 数据可能变化,列表视图需要重新加载 + const monthStart: number = new Date(this.displayYear, this.displayMonth, 1).getTime(); + const rangeStart: number = monthStart - 7 * 86400000; + const rangeEnd: number = monthStart + 32 * 86400000 + 60 * 86400000; + this.events = await CalendarDataService.loadEvents(context, rangeStart, rangeEnd, this.sources); + this.todos = await CalendarDataService.loadTodos(context, this.sources); + LogUtil.write(`界面刷新完成:日程 ${this.events.length} 条(显示区间内),待办 ${this.todos.length} 条`); + this.rebuildPages(); + // 当前正处于列表视图时立即重载,避免同步后列表空白 + if (this.mode === 'agenda') { + await this.ensureAgendaData(); + } + this.loading = false; + } + + /** 构建某年某月的 42 格 */ + private buildMonthCells(year: number, month: number): MonthCell[] { + const first: number = new Date(year, month, 1).getTime(); + const firstDay = new Date(first).getDay(); + const offset: number = (firstDay + 6) % 7; // 周一为一周开始 + const gridStart: number = first - offset * 86400000; + const todayStart: number = this.startOfDay(Date.now()); + const cells: MonthCell[] = []; + for (let i = 0; i < 42; i++) { + const dateMs: number = gridStart + i * 86400000; + const d = new Date(dateMs); + const cell = new MonthCell(); + cell.dateMs = dateMs; + cell.day = d.getDate(); + cell.inMonth = d.getMonth() === month; + cell.isToday = dateMs === todayStart; + cell.lunar = LunarUtil.lunarDayText(dateMs); + cells.push(cell); + } + return cells; + } + + /** 重建三页(上月/本月/下月) */ + private rebuildPages(): void { + const prev = this.addMonths(this.displayYear, this.displayMonth, -1); + const next = this.addMonths(this.displayYear, this.displayMonth, 1); + this.monthPages = [ + this.buildMonthCells(prev[0], prev[1]), + this.buildMonthCells(this.displayYear, this.displayMonth), + this.buildMonthCells(next[0], next[1]) + ]; + } + + private eventsOfDate(dateMs: number): DisplayEvent[] { + return this.events.filter((e: DisplayEvent): boolean => + this.startOfDay(e.startTime) <= dateMs && e.endTime >= dateMs); + } + + private switchMonth(delta: number): void { + const nm = this.addMonths(this.displayYear, this.displayMonth, delta); + this.displayYear = nm[0]; + this.displayMonth = nm[1]; + this.reloadEvents(); + } + + /** 回到今天:切回本月并选中今天 */ + private goToday(): void { + const now = new Date(); + this.displayYear = now.getFullYear(); + this.displayMonth = now.getMonth(); + this.selectedDate = this.startOfDay(now.getTime()); + this.rebuildPages(); + this.reloadEvents(); + } + + /** Swiper 翻页后回正到中间页并切换基准月份 */ + private handleSwiperChange(index: number): void { + if (this.swiperGuard) { + this.swiperGuard = false; + return; + } + if (index === 1) { + return; + } + const delta: number = index === 2 ? 1 : -1; + const nm = this.addMonths(this.displayYear, this.displayMonth, delta); + this.displayYear = nm[0]; + this.displayMonth = nm[1]; + this.reloadEvents(); + this.swiperGuard = true; + this.swiperController.changeIndex(1, false); + } + + // ---------- 同步 ---------- + private async syncSingle(accId: string): Promise { + const acc = this.accounts.find((a: DavAccount): boolean => a.id === accId); + if (acc === undefined) { + return; + } + if (acc.type !== TYPE_CALDAV) { + this.getUIContext().getPromptAction().showToast({ message: '该账号类型暂不支持日程同步' }); + return; + } + this.syncing = true; + try { + const context = this.getUIContext().getHostContext(); + if (context === undefined) { + return; + } + await SyncEngine.withTimeout( + SyncEngine.syncAccount(context as common.UIAbilityContext, acc), 120000); + 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: `同步失败:${e.message}` }); + } finally { + this.syncing = false; + await this.reloadAll(); + // 同步后刷新服务卡片 + try { + const ctx = this.getUIContext().getHostContext(); + if (ctx !== undefined) { + await CardDataService.pushToAllForms(ctx); + } + } catch (err) { + // 卡片刷新失败不影响主流程 + } + } + } + + 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 syncAll(manual: boolean): Promise { + if (this.syncing) { + return; + } + this.syncing = true; + const context = this.getUIContext().getHostContext(); + if (context === undefined) { + this.syncing = false; + return; + } + let ok: number = 0; + let failMsg: string = ''; + for (const acc of this.accounts) { + if (acc.type !== TYPE_CALDAV) { + continue; + } + try { + await SyncEngine.withTimeout( + SyncEngine.syncAccount(context as common.UIAbilityContext, acc), 120000); + acc.lastSyncTime = this.formatNow(); + ok++; + } catch (err) { + const e = err as BusinessError; + failMsg = e.message; + } + } + try { + await SyncEngine.settleLocalEvents(context); + await AccountStore.saveAll(context, this.accounts); + } catch (err) { + const e = err as BusinessError; + failMsg = e.message; + } + this.syncing = false; + if (failMsg !== '') { + this.getUIContext().getPromptAction() + .showToast({ message: ok > 0 ? `同步完成 ${ok} 个,失败:${failMsg}` : `同步失败:${failMsg}` }); + } else if (ok > 0 && manual) { + this.getUIContext().getPromptAction() + .showToast({ message: `同步完成(${ok} 个账号)` }); + } + await this.reloadAll(); + // 同步后刷新服务卡片 + 重建提醒 + try { + const ctx2 = this.getUIContext().getHostContext(); + if (ctx2 !== undefined) { + await CardDataService.pushToAllForms(ctx2); + await ReminderService.refreshReminders(ctx2); + } + } catch (err) { + // 卡片刷新失败不影响主流程 + } + } + + private openEvent(e: DisplayEvent): void { + if (e.isSystem) { + this.getUIContext().getPromptAction() + .showToast({ message: '系统日历日程,请在系统日历 App 中编辑' }); + return; + } + AppStorage.setOrCreate('pendingEventId', e.id); + router.pushUrl({ url: 'pages/EventEditPage' }); + } + + private addEvent(): void { + AppStorage.setOrCreate('pendingEventId', 0); + AppStorage.setOrCreate('pendingEventDate', this.selectedDate); + router.pushUrl({ url: 'pages/EventEditPage' }); + } + + // ---------- UI ---------- + build() { + Stack({ alignContent: Alignment.BottomEnd }) { + Column() { + this.header() + this.modeTabs() + if (this.mode === 'month') { + this.monthBody() + } else if (this.mode === 'week') { + this.weekBody() + } else if (this.mode === 'day') { + this.dayBody() + } else if (this.mode === 'todo') { + this.todoBody() + } else { + this.agendaBody() + } + } + .width('100%') + .height('100%') + .backgroundColor($r('app.color.page_bg')) + + 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.addEvent(); + }) + } + .width('100%') + .height('100%') + } + + @Builder + header() { + Row({ space: 10 }) { + Image($r('app.media.app_icon')) + .width(32) + .height(32) + .borderRadius(8) + .objectFit(ImageFit.Cover) + Text('同步日历') + .fontSize(20) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Blank() + Button('今天') + .fontSize(12) + .fontColor($r('app.color.brand')) + .backgroundColor(Color.Transparent) + .border({ width: 1, color: $r('app.color.brand'), radius: 14 }) + .height(28) + .padding({ left: 12, right: 12 }) + .margin({ right: 8 }) + .onClick(() => { + this.goToday(); + }) + Button() { + if (this.syncing) { + LoadingProgress() + .width(20) + .height(20) + .color($r('app.color.brand')) + } else { + Text('⟳') + .fontSize(18) + .fontColor($r('app.color.brand')) + .fontWeight(FontWeight.Bold) + } + } + .width(36) + .height(36) + .borderRadius(18) + .backgroundColor($r('app.color.card_bg')) + .border({ width: 1, color: $r('app.color.shadow_color') }) + .enabled(!this.syncing) + .onClick(() => { + this.syncAll(true); + }) + Button() { + Text('账') + .fontSize(14) + .fontColor($r('app.color.brand')) + .fontWeight(FontWeight.Medium) + } + .width(36) + .height(36) + .borderRadius(18) + .backgroundColor($r('app.color.card_bg')) + .border({ width: 1, color: $r('app.color.shadow_color') }) + .onClick(() => { + router.pushUrl({ url: 'pages/AccountsPage' }); + }) + Button() { + Text('设') + .fontSize(14) + .fontColor($r('app.color.brand')) + .fontWeight(FontWeight.Medium) + } + .width(36) + .height(36) + .borderRadius(18) + .backgroundColor($r('app.color.card_bg')) + .border({ width: 1, color: $r('app.color.shadow_color') }) + .onClick(() => { + router.pushUrl({ url: 'pages/SettingsPage' }); + }) + } + .width('100%') + .padding({ left: 20, right: 20, top: 12, bottom: 8 }) + } + + @Builder + modeTabs() { + Row({ space: 4 }) { + this.modeTab('month', '月') + this.modeTab('week', '周') + this.modeTab('day', '日') + this.modeTab('agenda', '列表') + this.modeTab('todo', '待办') + Blank() + if (this.mode === 'month') { + Text(this.fmtMonthTitle()) + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_primary')) + } else if (this.mode === 'todo') { + Text(`${this.todos.filter((t: DisplayEvent): boolean => !t.completed).length} 项未完成`) + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_primary')) + } else { + Text(this.fmtDateCn(this.selectedDate)) + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_primary')) + } + } + .width('100%') + .padding({ left: 16, right: 16, top: 4, bottom: 4 }) + } + + @Builder + modeTab(key: string, label: string) { + Text(label) + .fontSize(13) + .fontColor(this.mode === key ? $r('app.color.button_text') : $r('app.color.text_secondary')) + .width(label.length > 1 ? 48 : 36) + .height(28) + .textAlign(TextAlign.Center) + .borderRadius(14) + .backgroundColor(this.mode === key ? $r('app.color.brand') : $r('app.color.chip_off_bg')) + .onClick(() => { + this.handleModeSwitch(key); + }) + } + + /** 切换视图:进入列表视图时按需加载(今天 ~ 未来2年,已结束的不显示) */ + private async handleModeSwitch(key: string): Promise { + this.mode = key; + if (key === 'agenda') { + await this.ensureAgendaData(); + } + } + + private async ensureAgendaData(): Promise { + if (!this.agendaStale) { + return; + } + const context = this.getUIContext().getHostContext(); + if (context === undefined) { + return; + } + this.loading = true; + const now: number = Date.now(); + const start: number = this.startOfDay(now); + const end: number = now + 730 * 86400000; + try { + const raw: DisplayEvent[] = await CalendarDataService.loadEvents(context, start, end, this.sources); + // 只保留未结束的(跨天进行中的保留);跨天且已开始的归到今天,与卡片一致 + this.agendaEvents = raw.filter((e: DisplayEvent): boolean => e.endTime >= now - 3600000); + this.agendaGroupsData = this.buildAgendaGroups(this.agendaEvents); + this.agendaStale = false; + LogUtil.write(`列表视图加载:${this.agendaEvents.length} 条(今天~未来2年),分组 ${this.agendaGroupsData.length} 组`); + } catch (err) { + const e = err as BusinessError; + LogUtil.write(`列表视图加载失败:${e.message}`); + } + this.loading = false; + } + + /** 周条:周一到周日(周视图顶部 / 复用) */ + @Builder + weekStrip() { + Row({ space: 4 }) { + ForEach(this.weekStripDays(), (cell: MonthCell) => { + Column({ space: 2 }) { + Text(WEEK_LABELS[(new Date(cell.dateMs).getDay() + 6) % 7]) + .fontSize(11) + .fontColor(cell.dateMs === this.selectedDate + ? $r('app.color.button_text') : $r('app.color.text_hint')) + Text(String(cell.day)) + .fontSize(14) + .fontWeight(cell.dateMs === this.selectedDate || cell.isToday + ? FontWeight.Bold : FontWeight.Normal) + .fontColor(cell.dateMs === this.selectedDate + ? $r('app.color.button_text') : $r('app.color.text_primary')) + .width(30) + .height(30) + .textAlign(TextAlign.Center) + .borderRadius(15) + .backgroundColor(cell.dateMs === this.selectedDate + ? $r('app.color.selected_bg') + : (cell.isToday ? $r('app.color.today_bg') : Color.Transparent)) + Text(cell.lunar) + .fontSize(8) + .fontColor($r('app.color.text_hint')) + .maxLines(1) + } + .layoutWeight(1) + .onClick(() => { + this.selectedDate = cell.dateMs; + }) + }, (cell: MonthCell) => `${cell.dateMs}_${cell.dateMs === this.selectedDate}`) + } + .width('100%') + .padding({ left: 12, right: 12 }) + } + + private weekStripDays(): MonthCell[] { + // 以选中日期所在周(周一起)的 7 天 + const sel = new Date(this.selectedDate); + const offset: number = (sel.getDay() + 6) % 7; + const weekStart: number = this.selectedDate - offset * 86400000; + const todayStart: number = this.startOfDay(Date.now()); + const cells: MonthCell[] = []; + for (let i = 0; i < 7; i++) { + const dateMs: number = weekStart + i * 86400000; + const cell = new MonthCell(); + cell.dateMs = dateMs; + cell.day = new Date(dateMs).getDate(); + cell.isToday = dateMs === todayStart; + cell.lunar = LunarUtil.lunarDayText(dateMs); + cells.push(cell); + } + return cells; + } + + @Builder + monthBody() { + Column() { + // 星期表头 + Row() { + ForEach(WEEK_LABELS, (w: string) => { + Text(w) + .fontSize(12) + .fontColor($r('app.color.text_hint')) + .textAlign(TextAlign.Center) + .layoutWeight(1) + }, (w: string) => w) + } + .width('100%') + .padding({ left: 12, right: 12 }) + + // 三页月格,左右滑动切换月份 + Swiper(this.swiperController) { + ForEach(this.monthPages, (cells: MonthCell[]) => { + Column({ space: 2 }) { + ForEach(this.chunkCells(cells), (week: MonthCell[], idx: number) => { + Row({ space: 2 }) { + ForEach(week, (cell: MonthCell) => { + this.dayCell(cell) + }, (cell: MonthCell) => `${cell.dateMs}_${cell.dateMs === this.selectedDate}`) + } + .width('100%') + }, (week: MonthCell[], idx: number) => String(idx)) + } + .width('100%') + }, (cells: MonthCell[]) => String(cells[0].dateMs)) + } + .index(1) + .loop(false) + .indicator(false) + .width('100%') + .onChange((index: number) => { + this.handleSwiperChange(index); + }) + + // 当日日程列表 + Row({ space: 8 }) { + Text(this.fmtDateCn(this.selectedDate)) + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_secondary')) + Blank() + Text(this.fmtMonthTitle()) + .fontSize(12) + .fontColor($r('app.color.text_hint')) + } + .width('100%') + .padding({ left: 20, right: 20, top: 6 }) + + this.eventList() + } + .width('100%') + .layoutWeight(1) + } + + @Builder + weekBody() { + Column() { + Row({ space: 16 }) { + Text('‹') + .fontSize(22) + .fontColor($r('app.color.brand')) + .onClick(() => { + this.selectedDate -= 7 * 86400000; + }) + Blank() + Text('本周') + .fontSize(15) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Blank() + Text('›') + .fontSize(22) + .fontColor($r('app.color.brand')) + .onClick(() => { + this.selectedDate += 7 * 86400000; + }) + } + .width('100%') + .padding({ left: 20, right: 20, top: 4, bottom: 4 }) + + this.weekStrip() + + Row({ space: 8 }) { + Text(this.fmtDateCn(this.selectedDate)) + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_secondary')) + Blank() + } + .width('100%') + .padding({ left: 20, right: 20, top: 8 }) + + this.eventList() + } + .width('100%') + .layoutWeight(1) + } + + @Builder + dayBody() { + Column() { + Row({ space: 16 }) { + Text('‹') + .fontSize(22) + .fontColor($r('app.color.brand')) + .onClick(() => { + this.selectedDate -= 86400000; + }) + Blank() + Text(this.fmtDateCn(this.selectedDate)) + .fontSize(16) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Blank() + Text('›') + .fontSize(22) + .fontColor($r('app.color.brand')) + .onClick(() => { + this.selectedDate += 86400000; + }) + } + .width('100%') + .padding({ left: 20, right: 20, top: 6, bottom: 6 }) + + this.eventList() + } + .width('100%') + .layoutWeight(1) + } + + /** 单日日程列表(月/周/日共用) */ + @Builder + eventList() { + Scroll() { + Column({ space: 8 }) { + ForEach(this.eventsOfDate(this.selectedDate), (e: DisplayEvent) => { + this.eventRow(e) + }, (e: DisplayEvent) => `${e.isSystem ? 's' : 'l'}${e.id}_${e.startTime}`) + if (this.eventsOfDate(this.selectedDate).length === 0 && !this.loading) { + Text('当天没有日程') + .fontSize(13) + .fontColor($r('app.color.text_hint')) + .width('100%') + .textAlign(TextAlign.Center) + .padding(20) + } + } + .width('100%') + .padding({ left: 20, right: 20, top: 4, bottom: 90 }) + .constraintSize({ minHeight: '100%' }) + } + .layoutWeight(1) + .scrollBar(BarState.Off) + .edgeEffect(EdgeEffect.Spring) + .align(Alignment.Top) + } + + @Builder + agendaBody() { + List({ space: 6 }) { + ListItem() { + Row({ space: 8 }) { + if (this.loading) { + LoadingProgress() + .width(24) + .height(24) + .color($r('app.color.brand')) + } + Text(this.loading ? '正在加载全部日程…' : `共 ${this.agendaEvents.length} 条`) + .fontSize(12) + .fontColor($r('app.color.text_hint')) + } + .width('100%') + .padding({ top: 2, bottom: 2 }) + } + + ForEach(this.agendaGroupsData, (group: AgendaGroup) => { + ListItem() { + Text(group.label) + .fontSize(13) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_secondary')) + .width('100%') + .padding({ top: 6, bottom: 2 }) + } + ForEach(group.items, (e: DisplayEvent) => { + ListItem() { + this.eventRow(e) + } + }, (e: DisplayEvent) => `${e.isSystem ? 's' : 'l'}${e.id}_${e.startTime}_${e.title}`) + }, (group: AgendaGroup) => group.label) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Auto) + .edgeEffect(EdgeEffect.Spring) + .padding({ left: 20, right: 20, top: 6, bottom: 90 }) + .cachedCount(8) + } + + /** 列表视图分组:今天 ~ 未来的日程(跨天进行中的归今天,与卡片一致)。 + * 结果预计算到 @State,避免每次 build 重算导致卡顿 */ + private buildAgendaGroups(events: DisplayEvent[]): AgendaGroup[] { + const map: Map = new Map(); + const today: number = this.startOfDay(Date.now()); + for (const e of events) { + const spans: boolean = this.startOfDay(e.endTime) > this.startOfDay(e.startTime); + // 跨天且已开始:归今天;其余归开始日 + const key: number = spans && this.startOfDay(e.startTime) < today + ? today : this.startOfDay(e.startTime); + let arr: DisplayEvent[] | undefined = map.get(key); + if (arr === undefined) { + arr = []; + map.set(key, arr); + } + arr.push(e); + } + const keys: number[] = Array.from(map.keys()).sort((a: number, b: number): number => a - b); + const groups: AgendaGroup[] = []; + for (const key of keys) { + const items: DisplayEvent[] = map.get(key) ?? []; + items.sort((a: DisplayEvent, b: DisplayEvent): number => { + const aAll: boolean = a.isAllDay || + this.startOfDay(a.endTime) > this.startOfDay(a.startTime); + const bAll: boolean = b.isAllDay || + this.startOfDay(b.endTime) > this.startOfDay(b.startTime); + if (aAll !== bAll) { + return aAll ? -1 : 1; + } + return a.startTime - b.startTime; + }); + const g = new AgendaGroup(); + g.label = key === today ? '今天' : this.fmtDateCn(key); + g.items = items; + groups.push(g); + } + return groups; + } + + // ---------- 待办(VTODO) ---------- + private colorOfCal(key: string): string { + const found = this.sources.find((s: CalSource): boolean => s.calKey === key); + return found !== undefined ? found.color : '#9AA0A6'; + } + + private fmtDue(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())}`; + } + + /** 待办页:与日历视图完全分开,只读展示服务器端 VTODO */ + @Builder + todoBody() { + Scroll() { + Column({ space: 8 }) { + Text('未完成') + .fontSize(13) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_secondary')) + .width('100%') + ForEach(this.todos.filter((t: DisplayEvent): boolean => !t.completed), (t: DisplayEvent) => { + this.todoRow(t) + }, (t: DisplayEvent) => `t${t.id}_${t.completed}`) + if (this.todos.some((t: DisplayEvent): boolean => t.completed)) { + Text('已完成') + .fontSize(13) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_secondary')) + .width('100%') + .margin({ top: 10 }) + ForEach(this.todos.filter((t: DisplayEvent): boolean => t.completed), (t: DisplayEvent) => { + this.todoRow(t) + }, (t: DisplayEvent) => `t${t.id}_${t.completed}`) + } + if (this.todos.length === 0 && !this.loading) { + Column({ space: 8 }) { + Text('☑') + .fontSize(34) + .fontColor($r('app.color.text_hint')) + Text('没有待办事项') + .fontSize(14) + .fontColor($r('app.color.text_hint')) + Text('CalDAV 服务器日历本中的待办(VTODO)会同步显示在这里') + .fontSize(12) + .fontColor($r('app.color.text_hint')) + .textAlign(TextAlign.Center) + } + .width('100%') + .padding(32) + } + } + .width('100%') + .padding({ left: 20, right: 20, top: 8, bottom: 90 }) + .constraintSize({ minHeight: '100%' }) + } + .layoutWeight(1) + .scrollBar(BarState.Off) + .edgeEffect(EdgeEffect.Spring) + .align(Alignment.Top) + } + + @Builder + todoRow(t: DisplayEvent) { + Row({ space: 10 }) { + // 勾选框(颜色跟随日历本) + if (t.completed) { + Text('✓') + .fontSize(13) + .fontColor($r('app.color.button_text')) + .width(22) + .height(22) + .textAlign(TextAlign.Center) + .borderRadius(11) + .backgroundColor(this.colorOfCal(t.calKey)) + } else { + Text('') + .width(22) + .height(22) + .borderRadius(11) + .border({ width: 2, color: this.colorOfCal(t.calKey) }) + } + Column({ space: 3 }) { + Text(t.title === '' ? '(无标题待办)' : t.title) + .fontSize(15) + .fontColor(t.completed ? $r('app.color.text_hint') : $r('app.color.text_primary')) + .decoration({ type: t.completed ? TextDecorationType.LineThrough : TextDecorationType.None }) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(t.startTime > 0 ? `截止 ${this.fmtDue(t.startTime)}` : '无截止时间') + .fontSize(12) + .fontColor($r('app.color.text_secondary')) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + } + .width('100%') + .padding(10) + .borderRadius(12) + .backgroundColor($r('app.color.card_bg')) + .border({ width: 1, color: $r('app.color.shadow_color') }) + .opacity(t.completed ? 0.7 : 1) + .onClick(() => { + this.getUIContext().getPromptAction() + .showToast({ message: '待办来自服务器,请在群晖日历等源端修改' }); + }) + } + + /** 是否跨天(结束日期晚于开始日期) */ + private spansDays(e: DisplayEvent): boolean { + return this.startOfDay(e.endTime) > this.startOfDay(e.startTime); + } + + @Builder + eventRow(e: DisplayEvent) { + Row({ space: 10 }) { + Column() + .width(4) + .height(38) + .borderRadius(2) + .backgroundColor(e.color) + Column({ space: 3 }) { + Text(e.title === '' ? '(无标题)' : e.title) + .fontSize(15) + .fontColor($r('app.color.text_primary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Row({ space: 6 }) { + Text(e.isAllDay || this.spansDays(e) + ? '全天' : `${this.fmtTime(e.startTime)} - ${this.fmtTime(e.endTime)}`) + .fontSize(12) + .fontColor($r('app.color.text_secondary')) + if (e.recurring) { + Text('↻ 重复') + .fontSize(10) + .fontColor($r('app.color.text_hint')) + .padding({ left: 4, right: 4, top: 1, bottom: 1 }) + .borderRadius(4) + .backgroundColor($r('app.color.chip_off_bg')) + } + } + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + if (e.isSystem) { + Text('系统') + .fontSize(10) + .fontColor($r('app.color.text_hint')) + .padding({ left: 6, right: 6, top: 2, bottom: 2 }) + .borderRadius(6) + .backgroundColor($r('app.color.chip_off_bg')) + } + // 所属日历本:最右侧、垂直居中,颜色同日历本 + if (e.calName !== '') { + Text(e.calName) + .fontSize(11) + .fontColor(e.color) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .constraintSize({ maxWidth: '30%' }) + } + } + .alignItems(VerticalAlign.Center) + .width('100%') + .padding(10) + .borderRadius(12) + .backgroundColor($r('app.color.card_bg')) + .border({ width: 1, color: $r('app.color.shadow_color') }) + .onClick(() => { + this.openEvent(e); + }) + } + + @Builder + dayCell(cell: MonthCell) { + Column({ space: 2 }) { + Text(String(cell.day)) + .fontSize(13) + .fontWeight(cell.isToday || cell.dateMs === this.selectedDate ? FontWeight.Bold : FontWeight.Normal) + .fontColor(!cell.inMonth + ? $r('app.color.text_hint') + : (cell.dateMs === this.selectedDate ? $r('app.color.button_text') : $r('app.color.text_primary'))) + .width(26) + .height(26) + .textAlign(TextAlign.Center) + .borderRadius(13) + .backgroundColor(cell.dateMs === this.selectedDate + ? $r('app.color.selected_bg') + : (cell.isToday ? $r('app.color.today_bg') : Color.Transparent)) + Text(cell.lunar) + .fontSize(8) + .fontColor(cell.dateMs === this.selectedDate + ? $r('app.color.text_secondary') : $r('app.color.text_hint')) + .maxLines(1) + } + .layoutWeight(1) + .padding({ top: 4, bottom: 4 }) + .borderRadius(8) + .onClick(() => { + this.selectedDate = cell.dateMs; + if (!cell.inMonth) { + const d = new Date(cell.dateMs); + this.displayYear = d.getFullYear(); + this.displayMonth = d.getMonth(); + this.rebuildPages(); + this.swiperGuard = true; + this.swiperController.changeIndex(1, false); + } + }) + } + + /** 把 42 个格子按每周 7 个分组 */ + private chunkCells(cells: MonthCell[]): MonthCell[][] { + const weeks: MonthCell[][] = []; + for (let i = 0; i < cells.length; i += 7) { + weeks.push(cells.slice(i, i + 7)); + } + return weeks; + } +} + +/** 日程列表的按日分组 */ +class AgendaGroup { + label: string = ''; + items: DisplayEvent[] = []; +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets new file mode 100644 index 0000000..5d7c656 --- /dev/null +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -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 { + if (this.context === undefined) { + return; + } + await AppSettings.setShowSystemCalendar(this.context, value); + this.getUIContext().getPromptAction() + .showToast({ message: value ? '已开启系统日历混合显示,返回首页生效' : '已关闭系统日历混合显示,返回首页生效' }); + } + + private async saveInterval(minutes: number): Promise { + 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')) + } +} diff --git a/entry/src/main/ets/pages/widget/Widget2x2.ets b/entry/src/main/ets/pages/widget/Widget2x2.ets new file mode 100644 index 0000000..fa5db64 --- /dev/null +++ b/entry/src/main/ets/pages/widget/Widget2x2.ets @@ -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: {} + }); + }) + } +} diff --git a/entry/src/main/ets/pages/widget/Widget4x2.ets b/entry/src/main/ets/pages/widget/Widget4x2.ets new file mode 100644 index 0000000..31e3b3a --- /dev/null +++ b/entry/src/main/ets/pages/widget/Widget4x2.ets @@ -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: {} + }); + }) + } +} diff --git a/entry/src/main/ets/pages/widget/Widget4x4.ets b/entry/src/main/ets/pages/widget/Widget4x4.ets new file mode 100644 index 0000000..414b893 --- /dev/null +++ b/entry/src/main/ets/pages/widget/Widget4x4.ets @@ -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: {} + }); + }) + } +} diff --git a/entry/src/main/ets/pages/widget/Widget6x4.ets b/entry/src/main/ets/pages/widget/Widget6x4.ets new file mode 100644 index 0000000..55361bd --- /dev/null +++ b/entry/src/main/ets/pages/widget/Widget6x4.ets @@ -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: {} + }); + }) + } +} diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 new file mode 100644 index 0000000..c90c3d5 --- /dev/null +++ b/entry/src/main/module.json5 @@ -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" + } + ] + } + ] + }, +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/color.json b/entry/src/main/resources/base/element/color.json new file mode 100644 index 0000000..815377e --- /dev/null +++ b/entry/src/main/resources/base/element/color.json @@ -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" } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/float.json b/entry/src/main/resources/base/element/float.json new file mode 100644 index 0000000..a0a93dd --- /dev/null +++ b/entry/src/main/resources/base/element/float.json @@ -0,0 +1,8 @@ +{ + "float": [ + { + "name": "page_text_font_size", + "value": "50fp" + } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json new file mode 100644 index 0000000..7b78d0b --- /dev/null +++ b/entry/src/main/resources/base/element/string.json @@ -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": "日程大全" + } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/base/media/app_icon.png b/entry/src/main/resources/base/media/app_icon.png new file mode 100644 index 0000000..c802796 Binary files /dev/null and b/entry/src/main/resources/base/media/app_icon.png differ diff --git a/entry/src/main/resources/base/media/background.png b/entry/src/main/resources/base/media/background.png new file mode 100644 index 0000000..2c37672 Binary files /dev/null and b/entry/src/main/resources/base/media/background.png differ diff --git a/entry/src/main/resources/base/media/foreground.png b/entry/src/main/resources/base/media/foreground.png new file mode 100644 index 0000000..0642a46 Binary files /dev/null and b/entry/src/main/resources/base/media/foreground.png differ diff --git a/entry/src/main/resources/base/media/layered_image.json b/entry/src/main/resources/base/media/layered_image.json new file mode 100644 index 0000000..fb49920 --- /dev/null +++ b/entry/src/main/resources/base/media/layered_image.json @@ -0,0 +1,7 @@ +{ + "layered-image": + { + "background" : "$media:background", + "foreground" : "$media:foreground" + } +} \ No newline at end of file diff --git a/entry/src/main/resources/base/media/startIcon.png b/entry/src/main/resources/base/media/startIcon.png new file mode 100644 index 0000000..90e6d6d Binary files /dev/null and b/entry/src/main/resources/base/media/startIcon.png differ diff --git a/entry/src/main/resources/base/profile/backup_config.json b/entry/src/main/resources/base/profile/backup_config.json new file mode 100644 index 0000000..78f40ae --- /dev/null +++ b/entry/src/main/resources/base/profile/backup_config.json @@ -0,0 +1,3 @@ +{ + "allowToBackupRestore": true +} \ No newline at end of file diff --git a/entry/src/main/resources/base/profile/form_config.json b/entry/src/main/resources/base/profile/form_config.json new file mode 100644 index 0000000..3bf7963 --- /dev/null +++ b/entry/src/main/resources/base/profile/form_config.json @@ -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"] + } + ] +} diff --git a/entry/src/main/resources/base/profile/main_pages.json b/entry/src/main/resources/base/profile/main_pages.json new file mode 100644 index 0000000..29908f8 --- /dev/null +++ b/entry/src/main/resources/base/profile/main_pages.json @@ -0,0 +1,11 @@ +{ + "src": [ + "pages/Index", + "pages/AccountsPage", + "pages/AddAccountPage", + "pages/CalendarListPage", + "pages/EditAccountPage", + "pages/EventEditPage", + "pages/SettingsPage" + ] +} \ No newline at end of file diff --git a/hvigor/hvigor-config.json5 b/hvigor/hvigor-config.json5 new file mode 100644 index 0000000..5792cf7 --- /dev/null +++ b/hvigor/hvigor-config.json5 @@ -0,0 +1,17 @@ +{ + "modelVersion": "26.0.0", + "dependencies": { + }, + "execution": { + // "analyze": "normal", + // "daemon": true, + // "incremental": true, + // "parallel": true + }, + "logging": { + }, + "debugging": { + }, + "nodeOptions": { + } +} \ No newline at end of file diff --git a/hvigorfile.ts b/hvigorfile.ts new file mode 100644 index 0000000..47113e2 --- /dev/null +++ b/hvigorfile.ts @@ -0,0 +1,6 @@ +import { appTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: appTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins: [] /* Custom plugin to extend the functionality of Hvigor. */ +} \ No newline at end of file diff --git a/oh-package-lock.json5 b/oh-package-lock.json5 new file mode 100644 index 0000000..c5a91ec --- /dev/null +++ b/oh-package-lock.json5 @@ -0,0 +1,28 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "@ohos/hamock@1.0.0": "@ohos/hamock@1.0.0", + "@ohos/hypium@1.0.25": "@ohos/hypium@1.0.25" + }, + "packages": { + "@ohos/hamock@1.0.0": { + "name": "@ohos/hamock", + "version": "1.0.0", + "integrity": "sha512-K6lDPYc6VkKe6ZBNQa9aoG+ZZMiwqfcR/7yAVFSUGIuOAhPvCJAo9+t1fZnpe0dBRBPxj2bxPPbKh69VuyAtDg==", + "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hamock/-/hamock-1.0.0.har", + "registryType": "ohpm" + }, + "@ohos/hypium@1.0.25": { + "name": "@ohos/hypium", + "version": "1.0.25", + "integrity": "sha512-l6uO2pjl8HyEKdekLqQt7tUpWbDqX/42zoAzkagtUVZAW9jT6lMvbe54MVjoLxq/RwQGygRvi6j4GpypSMFSHw==", + "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.25.har", + "registryType": "ohpm" + } + } +} \ No newline at end of file diff --git a/oh-package.json5 b/oh-package.json5 new file mode 100644 index 0000000..d012b08 --- /dev/null +++ b/oh-package.json5 @@ -0,0 +1,10 @@ +{ + "modelVersion": "26.0.0", + "description": "Please describe the basic information.", + "dependencies": { + }, + "devDependencies": { + "@ohos/hypium": "1.0.25", + "@ohos/hamock": "1.0.0" + } +} \ No newline at end of file