首次提交:SyncCalendar 项目

This commit is contained in:
2026-09-13 15:50:37 +08:00
commit bbb3ec5334
53 changed files with 7123 additions and 0 deletions
+17
View File
@@ -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
+11
View File
@@ -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"
}
}
@@ -0,0 +1,8 @@
{
"string": [
{
"name": "app_name",
"value": "同步日历"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

@@ -0,0 +1,7 @@
{
"layered-image":
{
"background" : "$media:background",
"foreground" : "$media:foreground"
}
}
+56
View File
@@ -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"
]
}
]
}
]
}
+30
View File
@@ -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"
}
]
}
+6
View File
@@ -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. */
}
+23
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
{
"name": "entry",
"version": "1.0.0",
"description": "Please describe the basic information.",
"main": "",
"author": "",
"license": "",
"dependencies": {}
}
+172
View File
@@ -0,0 +1,172 @@
// entry/src/main/ets/common/AccountStore.ets
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
export const TYPE_CALDAV: string = 'caldav';
export const TYPE_CARDDAV: string = 'carddav';
export const TYPE_WEBDAV: string = 'webdav';
export const TYPE_KEYS: string[] = [TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV];
/** 本机事件所属的虚拟日历 key */
export const LOCAL_CAL_KEY: string = 'local';
/**
* DAV 账号(@Observed 使同步状态变化能刷新列表 UI)
*/
@Observed
export class DavAccount {
id: string = '';
type: string = TYPE_CALDAV;
name: string = '';
serverUrl: string = '';
username: string = '';
password: string = '';
calendarHrefs: string[] = [];
calendarNames: string[] = [];
/** 服务器端定义的日历本颜色(calendar-color),与 calendarHrefs 一一对应,空串表示未定义 */
calendarColors: string[] = [];
itemCount: number = 0;
lastSyncTime: string = '';
}
/** 日历本来源(用于界面显隐与取色) */
export class CalSource {
calKey: string = '';
name: string = '';
color: string = '#007DFF';
source: string = 'dav'; // 'dav' | 'system' | 'local'
visible: boolean = true;
}
/** 与 UI 无关的调色板 */
export class BookPalette {
static colors: string[] = [
'#007DFF', '#FF7D00', '#00B96B', '#9F44FF', '#F5317F', '#00B2C6', '#B58600', '#6D4AFF'
];
static colorFor(index: number): string {
return BookPalette.colors[index % BookPalette.colors.length];
}
}
/**
* 账号持久化:每个账号编码为一个分隔符字符串存储(acc_0、acc_1…),避免 JSON 结构化类型问题
*/
export class AccountStore {
private static readonly STORE: string = 'caldav_account';
private static readonly COUNT_KEY: string = 'accountCount';
/** 规范化颜色:#RRGGBBAA → #RRGGBB;非法返回空串 */
static normalizeColor(raw: string): string {
const v: string = raw.trim();
if (/^#[0-9A-Fa-f]{8}$/.test(v)) {
return '#' + v.substring(3, 9);
}
if (/^#[0-9A-Fa-f]{6}$/.test(v)) {
return v.toUpperCase();
}
return '';
}
private static encodeAccount(acc: DavAccount): string {
const safe = (s: string): string => s.split('|').join('∥');
const parts: string[] = [
safe(acc.type),
safe(acc.name),
safe(acc.serverUrl),
safe(acc.username),
safe(acc.password),
String(acc.itemCount),
safe(acc.lastSyncTime),
acc.calendarHrefs.join(';'),
acc.calendarNames.join(';'),
acc.calendarColors.join(';'),
safe(acc.id)
];
return parts.join('|');
}
private static decodeAccount(raw: string): DavAccount | null {
const parts: string[] = raw.split('|');
if (parts.length < 9) {
return null;
}
const acc = new DavAccount();
acc.type = parts[0] === '' ? TYPE_CALDAV : parts[0];
acc.name = parts[1];
acc.serverUrl = parts[2];
acc.username = parts[3];
acc.password = parts[4];
const count: number = Number(parts[5]);
acc.itemCount = Number.isNaN(count) ? 0 : count;
acc.lastSyncTime = parts[6];
acc.calendarHrefs = parts[7] === '' ? [] : parts[7].split(';');
acc.calendarNames = parts[8] === '' ? [] : parts[8].split(';');
if (parts.length >= 10) {
acc.calendarColors = parts[9] === '' ? [] : parts[9].split(';');
}
// 兼容旧版本存储(无 id 字段):加载时为空,由 loadAll 统一补发并持久化
if (parts.length >= 11) {
acc.id = parts[10];
}
return acc;
}
static async loadAll(context: common.Context): Promise<DavAccount[]> {
const result: DavAccount[] = [];
let migrated: boolean = false;
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AccountStore.STORE);
const count: number = await store.get(AccountStore.COUNT_KEY, 0) as number;
for (let i = 0; i < count; i++) {
const raw = await store.get(`acc_${i}`, '') as string;
if (raw === '') {
continue;
}
const acc = AccountStore.decodeAccount(raw);
if (acc !== null) {
if (acc.id === '') {
// 旧版本存储没有 id:补发一个并标记需要回写,保证 id 跨启动稳定
acc.id = `acc${Date.now()}_${i}`;
migrated = true;
}
result.push(acc);
}
}
} catch (err) {
const e = err as BusinessError;
console.error(`读取账号失败: ${e.message}`);
}
if (migrated) {
try {
await AccountStore.saveAll(context, result);
console.info('旧版账号数据已迁移:补充持久化账号 id');
} catch (err) {
const e = err as BusinessError;
console.error(`账号 id 迁移回写失败: ${e.message}`);
}
}
return result;
}
static async saveAll(context: common.Context, accounts: DavAccount[]): Promise<void> {
const store: preferences.Preferences =
await preferences.getPreferences(context, AccountStore.STORE);
const oldCount: number = await store.get(AccountStore.COUNT_KEY, 0) as number;
for (let i = 0; i < oldCount; i++) {
store.delete(`acc_${i}`);
}
for (let i = 0; i < accounts.length; i++) {
await store.put(`acc_${i}`, AccountStore.encodeAccount(accounts[i]));
}
await store.put(AccountStore.COUNT_KEY, accounts.length);
await store.flush();
}
static async addAccount(context: common.Context, acc: DavAccount): Promise<void> {
const list: DavAccount[] = await AccountStore.loadAll(context);
list.push(acc);
await AccountStore.saveAll(context, list);
}
}
+58
View File
@@ -0,0 +1,58 @@
// entry/src/main/ets/common/AppSettings.ets
// 应用级设置(preferences 持久化)
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
export class AppSettings {
private static readonly STORE: string = 'sync_settings';
private static readonly KEY_SHOW_SYSTEM: string = 'show_system_calendar';
private static readonly KEY_SYNC_INTERVAL: string = 'sync_interval_minutes';
/** 是否混合显示系统日历日程(默认开) */
static async getShowSystemCalendar(context: common.Context): Promise<boolean> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
return await store.get(AppSettings.KEY_SHOW_SYSTEM, true) as boolean;
} catch (err) {
return true;
}
}
static async setShowSystemCalendar(context: common.Context, value: boolean): Promise<void> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
await store.put(AppSettings.KEY_SHOW_SYSTEM, value);
await store.flush();
} catch (err) {
const e = err as BusinessError;
console.error(`保存设置失败: ${e.message}`);
}
}
/** 自动同步间隔(分钟),默认 1 分钟 */
static async getSyncIntervalMinutes(context: common.Context): Promise<number> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
const v: number = await store.get(AppSettings.KEY_SYNC_INTERVAL, 1) as number;
return v > 0 ? v : 1;
} catch (err) {
return 1;
}
}
static async setSyncIntervalMinutes(context: common.Context, minutes: number): Promise<void> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
await store.put(AppSettings.KEY_SYNC_INTERVAL, minutes);
await store.flush();
} catch (err) {
const e = err as BusinessError;
console.error(`保存同步间隔失败: ${e.message}`);
}
}
}
@@ -0,0 +1,316 @@
// entry/src/main/ets/common/CalendarDataService.ets
// 合并数据源:本地库(DAV 同步 + 本机事件) + 系统日历(只读展示)
import { common, abilityAccessCtrl, Permissions } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { calendarManager } from '@kit.CalendarKit';
import { AccountStore, CalSource, BookPalette, LOCAL_CAL_KEY, DavAccount } from './AccountStore';
import { EventDb, LocalEvent } from './EventDb';
import { LogUtil } from './LogUtil';
import { AppSettings } from './AppSettings';
import { RruleUtil } from './RruleUtil';
import { IcsUtil } from './IcsUtil';
/** 界面展示用事件 */
export class DisplayEvent {
id: number = 0; // 本地库 id 或系统日历事件 id
isSystem: boolean = false;
title: string = '';
description: string = '';
location: string = '';
startTime: number = 0;
endTime: number = 0;
isAllDay: boolean = false;
calKey: string = '';
calName: string = '';
color: string = '#007DFF';
completed: boolean = false; // 仅待办使用
recurring: boolean = false; // 是否为重复日程展开出的发生
}
export class CalendarDataService {
/** 申请读取全部日历权限(混合展示系统日历需要) */
static async ensureSystemCalendarPermission(context: common.UIAbilityContext): Promise<boolean> {
try {
const atManager = abilityAccessCtrl.createAtManager();
const permissions: Permissions[] = [
'ohos.permission.READ_CALENDAR',
'ohos.permission.READ_WHOLE_CALENDAR'
];
const result = await atManager.requestPermissionsFromUser(context, permissions);
for (const r of result.authResults) {
if (r !== 0) {
return false;
}
}
return true;
} catch (err) {
const e = err as BusinessError;
console.error(`日历权限申请失败: ${e.code} - ${e.message}`);
return false;
}
}
/** 收集全部日历来源:DAV 日历本 + 本机 + 系统日历账户 */
static async loadSources(context: common.Context): Promise<CalSource[]> {
const sources: CalSource[] = [];
try {
const accounts: DavAccount[] = await AccountStore.loadAll(context);
for (const acc of accounts) {
for (let i = 0; i < acc.calendarHrefs.length; i++) {
const s = new CalSource();
s.calKey = `${acc.id}_${i}`;
let name: string = i < acc.calendarNames.length ? acc.calendarNames[i] : '';
if (name === '') {
name = acc.calendarHrefs.length === 1 ? acc.name : `日历本 ${i + 1}`;
}
s.name = `${acc.name}:${name}`;
// 优先使用服务器端定义的颜色,未定义时按序号取调色板
let color: string = i < acc.calendarColors.length ? AccountStore.normalizeColor(acc.calendarColors[i]) : '';
s.color = color !== '' ? color : BookPalette.colorFor(i);
s.source = 'dav';
s.visible = true;
sources.push(s);
}
}
// 本机事件虚拟日历
const local = new CalSource();
local.calKey = LOCAL_CAL_KEY;
local.name = '本机';
local.color = '#5A6068';
local.source = 'local';
local.visible = true;
sources.push(local);
} catch (err) {
const e = err as BusinessError;
console.error(`加载 DAV 来源失败: ${e.message}`);
}
// 系统日历账户(受"混合显示系统日历"开关控制;按账户名去重)
const showSys: boolean = await AppSettings.getShowSystemCalendar(context);
if (!showSys) {
LogUtil.write('来源加载:系统日历混合显示已关闭,跳过全部系统日历');
} else {
try {
const uiContext = context as common.UIAbilityContext;
const mgr: calendarManager.CalendarManager = calendarManager.getCalendarManager(uiContext);
const calendars: calendarManager.Calendar[] = await mgr.getAllCalendars();
let sysRaw: number = 0;
for (const cal of calendars) {
try {
const account: calendarManager.CalendarAccount = cal.getAccount();
sysRaw++;
const sysKey: string = `sys_${account.name}`;
if (sources.some((x: CalSource): boolean => x.calKey === sysKey)) {
continue; // 同名账户只保留一个来源
}
const s = new CalSource();
s.calKey = sysKey;
s.name = '系统';
let color = '#9AA0A6';
const config: calendarManager.CalendarConfig = cal.getConfig();
if (typeof config.color === 'string') {
color = config.color;
}
s.color = color;
s.source = 'system';
s.visible = true;
sources.push(s);
} catch (err) {
// 单个日历账户读取失败不影响整体
}
}
LogUtil.write(`来源加载:系统日历 ${sysRaw} 个(按账户去重后保留,共 ${sources.length} 个来源)`);
} catch (err) {
const e = err as BusinessError;
console.info(`系统日历不可用(可能未授权): ${e.code}`);
}
}
return sources;
}
/** 加载时间区间内的合并事件(按来源显隐过滤) */
static async loadEvents(context: common.Context, start: number, end: number,
sources: CalSource[]): Promise<DisplayEvent[]> {
LogUtil.init(context);
const result: DisplayEvent[] = [];
const visibleKeys: string[] = sources.filter((s: CalSource): boolean => s.visible)
.map((s: CalSource): string => s.calKey);
const colorOf = (key: string): string => {
const found = sources.find((s: CalSource): boolean => s.calKey === key);
return found !== undefined ? found.color : '#9AA0A6';
};
const nameOf = (key: string): string => {
const found = sources.find((s: CalSource): boolean => s.calKey === key);
return found !== undefined ? found.name : '';
};
// 1) 本地库(DAV + 本机);重复日程(RRULE)按规则展开为窗口内的多次发生
try {
const rows: LocalEvent[] = await EventDb.queryRange(context, start, end);
// 重复主事件的首次发生可能在窗口外,也纳入(仅用于展开)
const masters: LocalEvent[] = await EventDb.queryRecurringMasters(context, end);
for (const m of masters) {
if (!rows.some((r: LocalEvent): boolean => r.id === m.id)) {
rows.push(m);
}
}
// 单次覆盖实例(RECURRENCE-ID 独立行):展开时跳过对应发生,避免重复
const overrideKeys: string[] = [];
for (const e of rows) {
if (e.rrule === '' && e.recurring) {
overrideKeys.push(`${e.uid}_${e.startTime}`);
}
}
let occTotal: number = 0;
for (const e of rows) {
if (!visibleKeys.includes(e.calKey)) {
continue;
}
const baseColor: string = colorOf(e.calKey);
const baseName: string = nameOf(e.calKey);
const dur: number = Math.max(0, e.endTime - e.startTime);
let times: number[] = [e.startTime];
if (e.rrule !== '') {
const exNums: number[] = [];
if (e.exdate !== '') {
for (const raw of e.exdate.split(';')) {
const t = IcsUtil.parseTime(raw, !raw.includes('T'));
if (t !== null) {
exNums.push(t.time);
}
}
}
const occs: number[] = RruleUtil.expand(e.rrule, e.startTime, start, end, exNums, 1500);
if (occs.length > 0) {
times = occs.filter((occ: number): boolean => !overrideKeys.includes(`${e.uid}_${occ}`));
}
}
for (const occ of times) {
const d = new DisplayEvent();
d.id = e.id;
d.isSystem = false;
d.title = e.title;
d.description = e.description;
d.location = e.location;
d.startTime = occ;
d.endTime = occ + dur;
d.isAllDay = e.isAllDay;
d.calKey = e.calKey;
d.calName = baseName;
d.color = baseColor;
d.recurring = e.rrule !== '';
result.push(d);
occTotal++;
}
}
LogUtil.write(`显示加载:本地库 ${rows.length} 行,展开后 ${occTotal} 条(区间 ${new Date(start).toLocaleString()} ~ ${new Date(end).toLocaleString()}`);
} catch (err) {
const e = err as BusinessError;
console.error(`读取本地事件失败: ${e.message}`);
}
// 2) 系统日历(只读混合展示)——单日历失败只跳过该日历,不影响其余
if (visibleKeys.some((k: string): boolean => k.startsWith('sys_'))) {
try {
const uiContext = context as common.UIAbilityContext;
const mgr: calendarManager.CalendarManager = calendarManager.getCalendarManager(uiContext);
const calendars: calendarManager.Calendar[] = await mgr.getAllCalendars();
for (const cal of calendars) {
let accountName: string = '';
let color = '#9AA0A6';
try {
const account: calendarManager.CalendarAccount = cal.getAccount();
accountName = account.name;
const config: calendarManager.CalendarConfig = cal.getConfig();
if (typeof config.color === 'string') {
color = config.color;
}
} catch (err) {
// 忽略单个日历
}
const sysKey: string = `sys_${accountName}`;
if (!visibleKeys.includes(sysKey)) {
continue;
}
try {
const events: calendarManager.Event[] = await cal.queryEventInstances(start, end);
let skipped: number = 0;
for (const ev of events) {
const d = new DisplayEvent();
d.id = ev.id !== undefined ? ev.id : 0;
d.isSystem = true;
d.title = ev.title !== undefined ? ev.title : '(无标题)';
d.description = ev.description !== undefined ? ev.description : '';
d.location = ev.location !== undefined && ev.location.location !== undefined ? ev.location.location : '';
d.startTime = ev.startTime;
d.endTime = ev.endTime;
d.isAllDay = ev.isAllDay === true;
d.calKey = sysKey;
d.calName = nameOf(sysKey);
d.color = color;
// 去重:与本地(DAV/本机)同标题+同时间的视为同一条,避免两个来源重复展示
const dup: boolean = result.some((x: DisplayEvent): boolean => !x.isSystem &&
x.title === d.title && x.startTime === d.startTime &&
x.endTime === d.endTime && x.isAllDay === d.isAllDay);
if (!dup) {
result.push(d);
} else {
skipped++;
}
}
LogUtil.write(`系统日历 ${sysKey}:查询到 ${events.length} 条,与本地重复跳过 ${skipped} 条`);
} catch (err) {
const e = err as BusinessError;
console.info(`查询系统日历 ${sysKey} 失败(跳过): ${e.code}`);
}
}
} catch (err) {
const e = err as BusinessError;
console.info(`读取系统日历失败(可能未授权): ${e.code}`);
}
}
// 排序:全天日程最前,然后按开始时间
result.sort((a: DisplayEvent, b: DisplayEvent): number => {
if (a.isAllDay !== b.isAllDay) {
return a.isAllDay ? -1 : 1;
}
return a.startTime - b.startTime;
});
return result;
}
/** 加载全部待办(VTODO,来自 DAV 同步,只读展示) */
static async loadTodos(context: common.Context, sources: CalSource[]): Promise<DisplayEvent[]> {
const result: DisplayEvent[] = [];
const visibleKeys: string[] = sources.filter((s: CalSource): boolean => s.visible)
.map((s: CalSource): string => s.calKey);
const colorOf = (key: string): string => {
const found = sources.find((s: CalSource): boolean => s.calKey === key);
return found !== undefined ? found.color : '#9AA0A6';
};
try {
const rows: LocalEvent[] = await EventDb.queryTodos(context);
for (const e of rows) {
if (!visibleKeys.includes(e.calKey)) {
continue;
}
const d = new DisplayEvent();
d.id = e.id;
d.isSystem = false;
d.title = e.title;
d.description = e.description;
d.location = e.location;
d.startTime = e.startTime;
d.endTime = e.endTime;
d.isAllDay = e.isAllDay;
d.calKey = e.calKey;
d.color = colorOf(e.calKey);
d.completed = e.completed;
result.push(d);
}
} catch (err) {
const e = err as BusinessError;
console.error(`读取待办失败: ${e.message}`);
}
return result;
}
}
@@ -0,0 +1,189 @@
// entry/src/main/ets/common/CardDataService.ets
// 服务卡片数据:从本地库取"今天起"的日程(含 RRULE 展开),输出 JSON 给卡片渲染
import { common } from '@kit.AbilityKit';
import { formBindingData, formProvider } from '@kit.FormKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { preferences } from '@kit.ArkData';
import { CalendarDataService, DisplayEvent } from './CalendarDataService';
import { LunarUtil } from './LunarUtil';
import { LogUtil } from './LogUtil';
/** 卡片单条日程(按天分组:组内全天事件在前、有时间的按开始时间排序) */
export class CardItem {
title: string = '';
time: string = ''; // 开始时间 '08:30' / '全天'
endTime: string = ''; // 结束时间 '10:00'(全天事件为空)
date: string = ''; // '9月15日'
showDate: boolean = false; // 是否为当天分组的第一条(卡片上渲染日期头)
calName: string = ''; // 所属日历本名(右侧显示,颜色同日历色)
color: string = '#007DFF';
}
/** 卡片整体数据 */
export class CardData {
eventsJson: string = '[]';
dateText: string = '';
lunarText: string = '';
}
export class CardDataService {
private static readonly MAX_ITEMS: number = 50;
// 已添加卡片的 formId 注册表(同进程内有效)
private static formIds: string[] = [];
// formIds 持久化(App 重启后内存注册表会清空,从 preferences 恢复,保证推送不丢卡片)
private static readonly PREF_STORE: string = 'card_form_ids';
private static readonly PREF_KEY: string = 'form_ids';
/** 注册卡片:内存 + 持久化 */
static async registerForm(context: common.Context, formId: string): Promise<void> {
if (formId !== '' && !CardDataService.formIds.includes(formId)) {
CardDataService.formIds.push(formId);
}
await CardDataService.saveFormIds(context);
}
/** 注销卡片:内存 + 持久化 */
static async unregisterForm(context: common.Context, formId: string): Promise<void> {
CardDataService.formIds = CardDataService.formIds.filter((id: string): boolean => id !== formId);
await CardDataService.saveFormIds(context);
}
private static async saveFormIds(context: common.Context): Promise<void> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, CardDataService.PREF_STORE);
await store.put(CardDataService.PREF_KEY, CardDataService.formIds.join(','));
await store.flush();
} catch (err) {
const e = err as BusinessError;
console.error(`保存 formIds 失败: ${e.message}`);
}
}
private static async loadPersistedFormIds(context: common.Context): Promise<void> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, CardDataService.PREF_STORE);
const raw: string = await store.get(CardDataService.PREF_KEY, '') as string;
for (const id of raw.split(',')) {
if (id !== '' && !CardDataService.formIds.includes(id)) {
CardDataService.formIds.push(id);
}
}
} catch (err) {
// 读取失败则仅用内存注册表
}
}
/** App 内同步完成后调用:刷新所有已添加的卡片 */
static async pushToAllForms(context: common.Context): Promise<void> {
// 先恢复持久化的 formId(App 重启后内存注册表为空)
await CardDataService.loadPersistedFormIds(context);
if (CardDataService.formIds.length === 0) {
return;
}
const data: CardData = await CardDataService.buildCardData(context);
const binding: formBindingData.FormBindingData =
formBindingData.createFormBindingData(data);
const stale: string[] = [];
for (const formId of CardDataService.formIds) {
try {
await formProvider.updateForm(formId, binding);
} catch (err) {
const e = err as BusinessError;
console.error(`卡片 ${formId} 刷新失败(移除注册): ${e.message}`);
stale.push(formId);
}
}
for (const id of stale) {
CardDataService.formIds = CardDataService.formIds.filter((x: string): boolean => x !== id);
}
if (stale.length > 0) {
await CardDataService.saveFormIds(context);
}
}
private static startOfDay(ms: number): number {
const d = new Date(ms);
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
}
/** 组装卡片数据(异步:查询本地库) */
static async buildCardData(context: common.Context): Promise<CardData> {
const data = new CardData();
try {
LogUtil.init(context);
const now = new Date();
const weekCn: string[] = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
data.dateText = `${now.getMonth() + 1}月${now.getDate()}日 ${weekCn[now.getDay()]}`;
data.lunarText = LunarUtil.lunarDayText(now.getTime());
const start: number = CardDataService.startOfDay(now.getTime());
const end: number = start + 60 * 86400000;
const sources = await CalendarDataService.loadSources(context);
const events: DisplayEvent[] = await CalendarDataService.loadEvents(context, start, end, sources);
// 1) 过滤已结束超过 1 小时的;2) 按天分组,组内全天在前、有时间按开始时间排序;
// 3) 平铺输出并给每组第一条打 showDate 标记(日期头只跟在自己日期前面)
// 跨天日程:已开始未结束的归到"今天"、按全天显示,不再从开始那天重复显示
const upcoming: DisplayEvent[] = events
.filter((e: DisplayEvent): boolean => e.endTime >= now.getTime() - 3600000);
const todayKey: number = CardDataService.startOfDay(now.getTime());
const dayKey = (ms: number): number => CardDataService.startOfDay(ms);
const spansDays = (e: DisplayEvent): boolean =>
dayKey(e.endTime) > dayKey(e.startTime);
const groups = new Map<number, DisplayEvent[]>();
for (const e of upcoming) {
// 跨天且已开始:归今天;其余归开始日
const key: number = spansDays(e) && dayKey(e.startTime) < todayKey
? todayKey : dayKey(e.startTime);
const arr = groups.get(key);
if (arr === undefined) {
groups.set(key, [e]);
} else {
arr.push(e);
}
}
const dayKeys: number[] = Array.from(groups.keys()).sort((a: number, b: number): number => a - b);
const items: CardItem[] = [];
const p = (n: number): string => n < 10 ? '0' + n : String(n);
outer:
for (const key of dayKeys) {
const list = groups.get(key) as DisplayEvent[];
const allDay = list.filter((e: DisplayEvent): boolean =>
e.isAllDay || spansDays(e)); // 跨天日程按全天展示
const timed = list.filter((e: DisplayEvent): boolean =>
!e.isAllDay && !spansDays(e))
.sort((a: DisplayEvent, b: DisplayEvent): number => a.startTime - b.startTime);
const ordered: DisplayEvent[] = allDay.concat(timed);
for (let i = 0; i < ordered.length; i++) {
if (items.length >= CardDataService.MAX_ITEMS) {
break outer;
}
const e = ordered[i];
const isDayLong: boolean = e.isAllDay || spansDays(e);
const d = new Date(e.startTime);
const item = new CardItem();
item.title = e.title === '' ? '(无标题)' : e.title;
item.time = isDayLong ? '全天' : `${p(d.getHours())}:${p(d.getMinutes())}`;
if (isDayLong) {
item.endTime = '';
} else {
const de = new Date(e.endTime);
item.endTime = `${p(de.getHours())}:${p(de.getMinutes())}`;
}
item.date = `${d.getMonth() + 1}月${d.getDate()}日`;
item.showDate = i === 0; // 当天分组第一条 → 卡片上显示日期头
item.calName = e.calName;
item.color = e.color;
items.push(item);
}
}
data.eventsJson = JSON.stringify(items);
LogUtil.write(`卡片数据刷新:${items.length} 条`);
} catch (err) {
LogUtil.write(`卡片数据刷新失败: ${JSON.stringify(err)}`);
}
return data;
}
}
+241
View File
@@ -0,0 +1,241 @@
// entry/src/main/ets/common/DavClient.ets
// CalDAV HTTP 客户端:REPORT / PUT / DELETE
import { http } from '@kit.NetworkKit';
import { buffer } from '@kit.ArkTS';
import { BusinessError } from '@kit.BasicServicesKit';
import { LogUtil } from './LogUtil';
/** REPORT 返回的单个远端资源 */
export class RemoteItem {
href: string = ''; // 资源完整 URL
etag: string = '';
ics: string = ''; // VCALENDAR 文本
}
/** PROPFIND 返回的集合颜色 */
export class DavColorEntry {
href: string = ''; // 集合路径(服务器返回的是路径,不带域名)
color: string = ''; // 规范化后的 #RRGGBB,可能为空
}
export class DavClient {
/**
* PROPFIND 拉取某路径下所有集合的 calendar-color
* 返回「集合路径 → 颜色」列表(路径为服务器返回的原始 href)
*/
static async propfindColors(serverUrl: string, auth: string): Promise<DavColorEntry[]> {
const requestBody: string = '<?xml version="1.0" encoding="utf-8"?>' +
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
'<d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
'</d:prop></d:propfind>';
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(serverUrl, {
method: 'PROPFIND' as http.RequestMethod,
header: {
'Authorization': auth,
'Content-Type': 'application/xml; charset=utf-8',
'Depth': '1',
'User-Agent': 'SyncCalendar/1.0'
},
extraData: requestBody,
connectTimeout: 10000,
readTimeout: 20000
});
console.info(`PROPFIND(颜色) 响应码: ${resp.responseCode}`);
LogUtil.write(`HTTP PROPFIND(颜色) ${serverUrl} → ${resp.responseCode}`);
if (resp.responseCode !== 207 && resp.responseCode !== 200) {
return [];
}
const xml: string = resp.result as string;
const result: DavColorEntry[] = [];
const blocks: string[] = xml.split(/<\/[\w-]*:?response>/i);
for (const block of blocks) {
if (!/<[\w-]*:?response[\s>]/i.test(block)) {
continue;
}
const href: string = DavClient.extractTag(block, 'href');
if (href === '') {
continue;
}
const resourcetype: string = DavClient.extractTag(block, 'resourcetype');
if (!/calendar/i.test(resourcetype)) {
continue;
}
const entry = new DavColorEntry();
entry.href = href;
// 两个命名空间都试:cs:getcolorCalendarServer/ ical:calendar-colorApple
let color: string = DavClient.normalizeHex(DavClient.extractTag(block, 'getcolor'));
if (color === '') {
color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color'));
}
entry.color = color;
result.push(entry);
}
if (result.length > 0 && result.every((e: DavColorEntry): boolean => e.color === '')) {
// 全部没拿到颜色时打印原始响应片段,便于诊断命名空间
console.info(`PROPFIND(颜色) 未取到颜色,响应片段: ${xml.substring(0, 600)}`);
}
return result;
} finally {
httpRequest.destroy();
}
}
/** 颜色规范化:#RRGGBBAA → #RRGGBB */
static normalizeHex(raw: string): string {
const v: string = raw.trim();
if (/^#[0-9A-Fa-f]{8}$/.test(v)) {
return '#' + v.substring(3, 9).toUpperCase();
}
if (/^#[0-9A-Fa-f]{6}$/.test(v)) {
return v.toUpperCase();
}
return '';
}
/** Basic Auth 头 */
static authHeader(username: string, password: string): string {
return 'Basic ' + buffer.from(`${username}:${password}`).toString('base64');
}
/** REPORT calendar-query:全量拉取某日历本内所有 VEVENT(不加时间范围,保证数据完整) */
static async reportCalendar(href: string, auth: string): Promise<RemoteItem[]> {
return DavClient.reportComponents(href, auth, 'VEVENT', '');
}
/** REPORT calendar-query:拉取某日历本内所有 VTODO 待办(不限时间范围,量小) */
static async reportTodos(href: string, auth: string): Promise<RemoteItem[]> {
return DavClient.reportComponents(href, auth, 'VTODO', '');
}
/** 通用 REPORT:按组件类型过滤拉取 calendar-data + getetag */
private static async reportComponents(href: string, auth: string,
compName: string, timeRange: string): Promise<RemoteItem[]> {
const body: string = '<?xml version="1.0" encoding="utf-8"?>' +
'<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">' +
'<d:prop><d:getetag/><c:calendar-data/></d:prop>' +
'<c:filter><c:comp-filter name="VCALENDAR">' +
`<c:comp-filter name="${compName}">` +
timeRange +
'</c:comp-filter></c:comp-filter></c:filter></c:calendar-query>';
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(href, {
method: 'REPORT' as http.RequestMethod,
header: {
'Authorization': auth,
'Content-Type': 'application/xml; charset=utf-8',
'Depth': '1',
'User-Agent': 'SyncCalendar/1.0'
},
extraData: body,
connectTimeout: 10000,
readTimeout: 60000
});
console.info(`REPORT(${compName}) ${href} 响应码: ${resp.responseCode}`);
LogUtil.write(`HTTP REPORT(${compName}) ${href} → ${resp.responseCode}`);
if (resp.responseCode === 401) {
LogUtil.write(`HTTP REPORT(${compName}) 401 拒绝凭据`);
throw new Error('服务器拒绝凭据(401)');
}
if (resp.responseCode < 200 || resp.responseCode >= 300) {
LogUtil.write(`HTTP REPORT(${compName}) 异常状态码 ${resp.responseCode}`);
throw new Error(`服务器返回状态码 ${resp.responseCode}`);
}
const xml: string = resp.result as string;
LogUtil.write(`HTTP REPORT(${compName}) 响应体 ${xml.length} 字符`);
const originMatch = /https?:\/\/[^/]+/i.exec(href);
const origin: string = originMatch !== null ? originMatch[0] : '';
const items: RemoteItem[] = [];
const blocks: string[] = xml.split(/<\/[\w-]*:?response>/i);
for (const block of blocks) {
if (!/<[\w-]*:?response[\s>]/i.test(block)) {
continue;
}
const resHref: string = DavClient.extractTag(block, 'href');
if (resHref === '') {
continue;
}
const etag: string = DavClient.extractTag(block, 'getetag').replace(/"/g, '');
const start: number = block.indexOf('BEGIN:VCALENDAR');
const end: number = block.indexOf('END:VCALENDAR');
if (start < 0 || end < 0) {
continue;
}
const item = new RemoteItem();
item.href = resHref.startsWith('http') ? resHref : origin + resHref;
item.etag = etag;
item.ics = block.substring(start, end + 'END:VCALENDAR'.length);
items.push(item);
}
return items;
} finally {
httpRequest.destroy();
}
}
/** PUT 新建/更新远端事件,返回响应 ETag(可能为空) */
static async putEvent(url: string, auth: string, ics: string): Promise<string> {
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(url, {
method: http.RequestMethod.PUT,
header: {
'Authorization': auth,
'Content-Type': 'text/calendar; charset=utf-8',
'User-Agent': 'SyncCalendar/1.0'
},
extraData: ics,
connectTimeout: 10000,
readTimeout: 30000
});
console.info(`PUT ${url} 响应码: ${resp.responseCode}`);
LogUtil.write(`HTTP PUT ${url} → ${resp.responseCode}`);
if (resp.responseCode < 200 || resp.responseCode >= 300) {
throw new Error(`推送失败,服务器返回 ${resp.responseCode}`);
}
const headers = resp.header as Record<string, string>;
if (headers !== undefined && headers !== null) {
const etag = headers['ETag'] ?? headers['etag'] ?? '';
return typeof etag === 'string' ? etag.replace(/"/g, '') : '';
}
return '';
} finally {
httpRequest.destroy();
}
}
/** DELETE 远端事件 */
static async deleteRemote(url: string, auth: string): Promise<void> {
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(url, {
method: http.RequestMethod.DELETE,
header: {
'Authorization': auth,
'User-Agent': 'SyncCalendar/1.0'
},
connectTimeout: 10000,
readTimeout: 30000
});
console.info(`DELETE ${url} 响应码: ${resp.responseCode}`);
LogUtil.write(`HTTP DELETE ${url} → ${resp.responseCode}`);
// 404 视为已删除,成功
if ((resp.responseCode < 200 || resp.responseCode >= 300) && resp.responseCode !== 404) {
throw new Error(`删除失败,服务器返回 ${resp.responseCode}`);
}
} finally {
httpRequest.destroy();
}
}
/** 提取任意命名空间前缀标签的内容 */
static extractTag(xml: string, tag: string): string {
const pattern: string = '<([\\w-]+:)?' + tag + '(?:\\s[^>]*)?>([\\s\\S]*?)<\\/([\\w-]+:)?' + tag + '>';
const regex: RegExp = new RegExp(pattern, 'i');
const match = regex.exec(xml);
return match !== null ? match[2].trim() : '';
}
}
+434
View File
@@ -0,0 +1,434 @@
// entry/src/main/ets/common/EventDb.ets
// 本地事件数据库(relationalStore):DAV 同步来的日程与本地新建日程统一存储
import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
/** 本地事件行 */
export class LocalEvent {
id: number = 0;
uid: string = '';
calKey: string = ''; // 'accId_idx' 或 'local'
href: string = ''; // 所属日历本集合 URL(local 为空)
remotePath: string = ''; // 集合内资源文件名,如 <uid>.icslocal 为空)
title: string = '';
description: string = '';
location: string = '';
startTime: number = 0; // 13 位毫秒;全天日程为当天 0 点
endTime: number = 0; // 全天日程为排他结束日前一毫秒(沿用 iCal 约定减 1s 存储)
isAllDay: boolean = false;
etag: string = '';
dirty: boolean = false; // 本地有修改,待推送
deleted: boolean = false; // 本地已删除,待推送
recurring: boolean = false; // 重复日程实例(暂不支持推送,避免破坏服务器序列)
kind: string = 'event'; // 'event' 日程 | 'todo' 待办(VTODO,只读展示)
completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED
rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生
exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔
reminder: number = 0; // 提醒提前分钟数(来自 VALARM,0 = 不提醒)
}
/** 远端事件条目(REPORT 解析结果) */
export class RemoteEvent {
uid: string = '';
etag: string = '';
title: string = '';
description: string = '';
location: string = '';
startTime: number = 0;
endTime: number = 0;
isAllDay: boolean = false;
recurring: boolean = false; // 含 RRULE 或 RECURREIENCE-ID 的实例
isTodo: boolean = false; // VTODO 待办
completed: boolean = false; // VTODO STATUS:COMPLETED
rrule: string = ''; // 原始 RRULE
exdate: string = ''; // 原始 EXDATE(分号分隔)
reminder: number = 0; // 提醒提前分钟数(VALARM)
}
export class EventDb {
private static db: relationalStore.RdbStore | null = null;
static async getDb(context: common.Context): Promise<relationalStore.RdbStore> {
if (EventDb.db !== null) {
return EventDb.db;
}
const config: relationalStore.StoreConfig = {
name: 'sync_calendar.db',
securityLevel: relationalStore.SecurityLevel.S1
};
const store = await relationalStore.getRdbStore(context, config);
await store.executeSql(
'CREATE TABLE IF NOT EXISTS events (' +
'id INTEGER PRIMARY KEY AUTOINCREMENT, ' +
'uid TEXT, cal_key TEXT, href TEXT, remote_path TEXT, ' +
'title TEXT, description TEXT, location TEXT, ' +
'start_time INTEGER, end_time INTEGER, is_all_day INTEGER, ' +
'etag TEXT, dirty INTEGER, deleted INTEGER, recurring INTEGER)'
);
// 旧版本库补列(已存在会抛错,忽略)
try {
await store.executeSql('ALTER TABLE events ADD COLUMN recurring INTEGER DEFAULT 0');
} catch (err) {
// 列已存在
}
try {
await store.executeSql("ALTER TABLE events ADD COLUMN kind TEXT DEFAULT 'event'");
} catch (err) {
// 列已存在
}
try {
await store.executeSql('ALTER TABLE events ADD COLUMN completed INTEGER DEFAULT 0');
} catch (err) {
// 列已存在
}
try {
await store.executeSql("ALTER TABLE events ADD COLUMN rrule TEXT DEFAULT ''");
} catch (err) {
// 列已存在
}
try {
await store.executeSql("ALTER TABLE events ADD COLUMN exdate TEXT DEFAULT ''");
} catch (err) {
// 列已存在
}
try {
await store.executeSql('ALTER TABLE events ADD COLUMN reminder INTEGER DEFAULT 0');
} catch (err) {
// 列已存在
}
EventDb.db = store;
return store;
}
private static fromRow(rs: relationalStore.ResultSet): LocalEvent {
const e = new LocalEvent();
e.id = rs.getLong(rs.getColumnIndex('id'));
e.uid = rs.getString(rs.getColumnIndex('uid'));
e.calKey = rs.getString(rs.getColumnIndex('cal_key'));
e.href = rs.getString(rs.getColumnIndex('href'));
e.remotePath = rs.getString(rs.getColumnIndex('remote_path'));
e.title = rs.getString(rs.getColumnIndex('title'));
e.description = rs.getString(rs.getColumnIndex('description'));
e.location = rs.getString(rs.getColumnIndex('location'));
e.startTime = rs.getLong(rs.getColumnIndex('start_time'));
e.endTime = rs.getLong(rs.getColumnIndex('end_time'));
e.isAllDay = rs.getLong(rs.getColumnIndex('is_all_day')) === 1;
e.etag = rs.getString(rs.getColumnIndex('etag'));
e.dirty = rs.getLong(rs.getColumnIndex('dirty')) === 1;
e.deleted = rs.getLong(rs.getColumnIndex('deleted')) === 1;
e.recurring = rs.getLong(rs.getColumnIndex('recurring')) === 1;
const kind: string = rs.getString(rs.getColumnIndex('kind'));
e.kind = kind === '' ? 'event' : kind;
e.completed = rs.getLong(rs.getColumnIndex('completed')) === 1;
e.rrule = rs.getString(rs.getColumnIndex('rrule'));
e.exdate = rs.getString(rs.getColumnIndex('exdate'));
e.reminder = rs.getLong(rs.getColumnIndex('reminder'));
return e;
}
private static toBucket(e: LocalEvent): relationalStore.ValuesBucket {
const bucket: relationalStore.ValuesBucket = {
'uid': e.uid,
'cal_key': e.calKey,
'href': e.href,
'remote_path': e.remotePath,
'title': e.title,
'description': e.description,
'location': e.location,
'start_time': e.startTime,
'end_time': e.endTime,
'is_all_day': e.isAllDay ? 1 : 0,
'etag': e.etag,
'dirty': e.dirty ? 1 : 0,
'deleted': e.deleted ? 1 : 0,
'recurring': e.recurring ? 1 : 0,
'kind': e.kind,
'completed': e.completed ? 1 : 0,
'rrule': e.rrule,
'exdate': e.exdate,
'reminder': e.reminder
};
return bucket;
}
/** 新建本地事件 */
static async insertLocal(context: common.Context, e: LocalEvent): Promise<number> {
const store = await EventDb.getDb(context);
e.dirty = true;
const rowId = await store.insert('events', EventDb.toBucket(e));
return rowId;
}
/** 更新本地事件(置 dirty 待推送) */
static async updateLocal(context: common.Context, e: LocalEvent): Promise<void> {
const store = await EventDb.getDb(context);
e.dirty = true;
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', e.id);
await store.update(EventDb.toBucket(e), predicates);
}
/** 标记删除(待推送 DELETE */
static async markDeleted(context: common.Context, id: number): Promise<void> {
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = { 'deleted': 1, 'dirty': 1 };
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(bucket, predicates);
}
/** 推送成功后清除 dirty(可回写 etag */
static async clearDirty(context: common.Context, id: number, etag: string): Promise<void> {
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = { 'dirty': 0, 'etag': etag };
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(bucket, predicates);
}
/** 推送删除成功后物理删除 */
static async purge(context: common.Context, id: number): Promise<void> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.delete(predicates);
}
/** 所有待推送事件 */
static async getDirty(context: common.Context): Promise<LocalEvent[]> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('dirty', 1);
const rs = await store.query(predicates);
const list: LocalEvent[] = [];
try {
while (rs.goToNextRow()) {
list.push(EventDb.fromRow(rs));
}
} finally {
rs.close();
}
return list;
}
/** 查询时间区间内未删除的日程(排除待办,待办单独展示) */
static async queryRange(context: common.Context, start: number, end: number): Promise<LocalEvent[]> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('deleted', 0).and().equalTo('kind', 'event')
.and().lessThanOrEqualTo('start_time', end)
.and().greaterThanOrEqualTo('end_time', start);
const rs = await store.query(predicates);
const list: LocalEvent[] = [];
try {
while (rs.goToNextRow()) {
list.push(EventDb.fromRow(rs));
}
} finally {
rs.close();
}
return list;
}
/** 单个事件 */
static async getById(context: common.Context, id: number): Promise<LocalEvent | null> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
const rs = await store.query(predicates);
let result: LocalEvent | null = null;
try {
if (rs.goToNextRow()) {
result = EventDb.fromRow(rs);
}
} finally {
rs.close();
}
return result;
}
/** 全部未删除的待办(VTODO),未完成在前、按截止时间升序 */
static async queryTodos(context: common.Context): Promise<LocalEvent[]> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('deleted', 0).and().equalTo('kind', 'todo');
const rs = await store.query(predicates);
const list: LocalEvent[] = [];
try {
while (rs.goToNextRow()) {
list.push(EventDb.fromRow(rs));
}
} finally {
rs.close();
}
list.sort((a: LocalEvent, b: LocalEvent): number => {
if (a.completed !== b.completed) {
return a.completed ? 1 : -1;
}
return a.startTime - b.startTime;
});
return list;
}
/** 全部重复主事件(有 RRULE 且开始时间在 before 之前,用于跨窗口展开) */
static async queryRecurringMasters(context: common.Context, before: number): Promise<LocalEvent[]> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('deleted', 0).and().equalTo('kind', 'event')
.and().notEqualTo('rrule', '').and().lessThanOrEqualTo('start_time', before);
const rs = await store.query(predicates);
const list: LocalEvent[] = [];
try {
while (rs.goToNextRow()) {
list.push(EventDb.fromRow(rs));
}
} finally {
rs.close();
}
return list;
}
/** 未来 7 天内需要提醒的日程(reminder > 0),按开始时间升序 */
static async queryRemindable(context: common.Context, from: number, to: number): Promise<LocalEvent[]> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('deleted', 0).and().equalTo('kind', 'event')
.and().greaterThan('reminder', 0)
.and().greaterThanOrEqualTo('start_time', from)
.and().lessThanOrEqualTo('start_time', to)
.orderByAsc('start_time').limitAs(50);
const rs = await store.query(predicates);
const list: LocalEvent[] = [];
try {
while (rs.goToNextRow()) {
list.push(EventDb.fromRow(rs));
}
} finally {
rs.close();
}
return list;
}
/**
* 用远端数据刷新某个日历本(增量):
* - etag 未变的跳过;变化的更新;远端没有的本地图删掉(排除本地待推送的新事件)
* - 返回统计描述:"新增X 更新Y 删除Z 不变W"
*/
static async applyRemote(context: common.Context, calKey: string, href: string,
remote: RemoteEvent[], isTodo: boolean): Promise<string> {
const store = await EventDb.getDb(context);
const kind: string = isTodo ? 'todo' : 'event';
let added: number = 0;
let updated: number = 0;
let removed: number = 0;
let unchanged: number = 0;
// 读取该日历本当前所有行(仅同类型)
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('kind', kind);
const rs = await store.query(predicates);
const existing: LocalEvent[] = [];
try {
while (rs.goToNextRow()) {
existing.push(EventDb.fromRow(rs));
}
} finally {
rs.close();
}
const remoteKeys: string[] = [];
for (const r of remote) {
// 重复日程实例共享 UID,唯一标识 = uid + 开始时间
const key: string = `${r.uid}_${r.startTime}`;
remoteKeys.push(key);
const found = existing.find((x: LocalEvent): boolean =>
!x.dirty && x.uid === r.uid && x.startTime === r.startTime);
if (found === undefined) {
// 新增
const e = new LocalEvent();
e.uid = r.uid;
e.calKey = calKey;
e.href = href;
e.remotePath = encodeURIComponent(r.uid) + '.ics';
e.title = r.title;
e.description = r.description;
e.location = r.location;
e.startTime = r.startTime;
e.endTime = r.endTime;
e.isAllDay = r.isAllDay;
e.etag = r.etag;
e.dirty = false;
e.deleted = false;
e.recurring = r.recurring;
e.kind = kind;
e.completed = r.completed;
e.rrule = r.rrule;
e.exdate = r.exdate;
e.reminder = r.reminder;
await store.insert('events', EventDb.toBucket(e));
added++;
} else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate
|| found.reminder !== r.reminder) {
// 更新(rrule/exdate 变化也更新,兼容老数据回填)
found.title = r.title;
found.description = r.description;
found.location = r.location;
found.startTime = r.startTime;
found.endTime = r.endTime;
found.isAllDay = r.isAllDay;
found.etag = r.etag;
found.dirty = false;
found.deleted = false;
found.recurring = r.recurring;
found.kind = kind;
found.completed = r.completed;
found.rrule = r.rrule;
found.exdate = r.exdate;
found.reminder = r.reminder;
const up = new relationalStore.RdbPredicates('events');
up.equalTo('id', found.id);
await store.update(EventDb.toBucket(found), up);
updated++;
} else {
unchanged++;
}
}
// 删除远端已不存在的(排除本地修改未推送的)
for (const local of existing) {
if (local.dirty) {
continue;
}
const localKey: string = `${local.uid}_${local.startTime}`;
if (!remoteKeys.includes(localKey)) {
const del = new relationalStore.RdbPredicates('events');
del.equalTo('id', local.id);
await store.delete(del);
removed++;
}
}
return `新增${added} 更新${updated} 删除${removed} 不变${unchanged}`;
}
/** 删除某账号全部本地事件(删除账号时调用) */
static async deleteAccountEvents(context: common.Context, accId: string): Promise<void> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.like(`cal_key`, `${accId}%`);
await store.delete(predicates);
}
/**
* 重新勾选日历本后清理失效数据:
* 删除该账号下 calKey 不在有效列表中的本地日程/待办(calKey = accId_序号,重选后序号会变)
*/
static async pruneAccountEvents(context: common.Context, accId: string,
validCalKeys: string[]): Promise<void> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.like('cal_key', `${accId}_%`);
if (validCalKeys.length > 0) {
predicates.and().notIn('cal_key', validCalKeys);
}
await store.delete(predicates);
}
}
+308
View File
@@ -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 TRIGGER0 = 不提醒)
}
/** 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 DURATIONISO 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;
}
}
+25
View File
@@ -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 {
}
/** 仅输出到 consolehilog),不再写文件 */
static write(msg: string): void {
console.info(`SyncLog ${msg}`);
}
static readAll(context: common.Context): string {
return '(日志功能已移除)';
}
static clear(context: common.Context): void {
}
}
+166
View File
@@ -0,0 +1,166 @@
// entry/src/main/ets/common/LunarUtil.ets
// 公历 → 农历(1901~2099),经典压缩表算法
export class LunarDate {
year: number = 0;
month: number = 0; // 1~12
day: number = 0; // 1~30
isLeap: boolean = false;
}
export class LunarUtil {
// 农历压缩数据表:每年一个十六进制值(1900~2100)
private static readonly LUNAR_INFO: number[] = [
0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2, // 1900-1909
0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977, // 1910-1919
0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970, // 1920-1929
0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950, // 1930-1939
0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557, // 1940-1949
0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0, // 1950-1959
0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0, // 1960-1969
0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6, // 1970-1979
0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570, // 1980-1989
0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x05ac0, 0x0ab60, 0x096d5, 0x092e0, // 1990-1999
0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5, // 2000-2009
0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930, // 2010-2019
0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530, // 2020-2029
0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45, // 2030-2039
0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0, // 2040-2049
0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06b20, 0x1a6c4, 0x0aae0, // 2050-2059
0x0a2e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4, // 2060-2069
0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0, // 2070-2079
0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160, // 2080-2089
0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a2d0, 0x0d150, 0x0f252, // 2090-2099
0x0d520 // 2100
];
private static readonly MONTH_CN: string[] =
['正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊'];
private static readonly DIGIT_CN: string[] =
['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
/** 农历闰月是哪个月(0 表示无闰月) */
private static leapMonth(y: number): number {
return LunarUtil.LUNAR_INFO[y - 1900] & 0xf;
}
/** 农历闰月天数 */
private static leapDays(y: number): number {
if (LunarUtil.leapMonth(y) === 0) {
return 0;
}
return (LunarUtil.LUNAR_INFO[y - 1900] & 0x10000) !== 0 ? 30 : 29;
}
/** 农历 y 年 m 月(非闰月)天数 */
private static monthDays(y: number, m: number): number {
return (LunarUtil.LUNAR_INFO[y - 1900] & (0x10000 >> m)) !== 0 ? 30 : 29;
}
/** 农历 y 年总天数 */
private static yearDays(y: number): number {
let sum: number = 348;
for (let i: number = 0x8000; i > 0x8; i >>= 1) {
sum += (LunarUtil.LUNAR_INFO[y - 1900] & i) !== 0 ? 1 : 0;
}
return sum + LunarUtil.leapDays(y);
}
/** 公历毫秒 → 农历日期(超出 1901~2099 返回 null */
static lunar(ms: number): LunarDate | null {
const d = new Date(ms);
const year: number = d.getFullYear();
const month: number = d.getMonth() + 1;
const day: number = d.getDate();
if (year < 1901 || year > 2099) {
return null;
}
let offset: number =
Math.floor((Date.UTC(year, month - 1, day) - Date.UTC(1900, 0, 31)) / 86400000);
let i: number = 1900;
let temp: number = 0;
for (i = 1900; i < 2101 && offset > 0; i++) {
temp = LunarUtil.yearDays(i);
offset -= temp;
}
if (offset < 0) {
offset += temp;
i--;
}
const leap: number = LunarUtil.leapMonth(i);
let isLeap: boolean = false;
let m: number = 1;
for (m = 1; m < 13 && offset > 0; m++) {
if (leap > 0 && m === leap + 1 && isLeap === false) {
--m;
isLeap = true;
temp = LunarUtil.leapDays(i);
} else {
temp = LunarUtil.monthDays(i, m);
}
if (isLeap === true && m === leap + 1) {
isLeap = false;
}
offset -= temp;
}
if (offset === 0 && leap > 0 && m === leap + 1) {
if (isLeap) {
isLeap = false;
} else {
isLeap = true;
--m;
}
}
if (offset < 0) {
offset += temp;
--m;
}
const result = new LunarDate();
result.year = i;
result.month = m;
result.day = offset + 1;
result.isLeap = isLeap;
return result;
}
/** 农历日名称:初一~三十 */
static dayName(day: number): string {
const dg: string[] = LunarUtil.DIGIT_CN;
if (day === 10) {
return '初十';
}
if (day === 20) {
return '二十';
}
if (day === 30) {
return '三十';
}
if (day < 10) {
return '初' + dg[day - 1];
}
if (day < 20) {
return '十' + dg[day - 11];
}
return '廿' + dg[day - 21];
}
/** 农历月名称:正月/二月/…/冬月/腊月,闰月带"闰" */
static monthName(month: number, isLeap: boolean): string {
const base: string = (month >= 1 && month <= 12) ? LunarUtil.MONTH_CN[month - 1] : '';
return (isLeap ? '闰' : '') + base + '月';
}
/**
* 日历格子上显示的农历文本:
* 初一时显示月名(如"九月"),其余显示日名(如"初五"、"十五"
*/
static lunarDayText(ms: number): string {
const info = LunarUtil.lunar(ms);
if (info === null) {
return '';
}
if (info.day === 1) {
return LunarUtil.monthName(info.month, info.isLeap);
}
return LunarUtil.dayName(info.day);
}
}
@@ -0,0 +1,65 @@
// entry/src/main/ets/common/ReminderService.ets
// 日程提醒:基于后台代理提醒(reminderAgentManager),
// 在日程开始前 N 分钟(来自服务器 VALARM TRIGGER)发系统通知,可右滑删除
import { reminderAgentManager } from '@kit.BackgroundTasksKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { EventDb, LocalEvent } from './EventDb';
import { LogUtil } from './LogUtil';
export class ReminderService {
private static readonly MAX_REMINDERS: number = 30; // 系统对单应用代理提醒数量有限制
private static readonly WINDOW: number = 7 * 86400000; // 只排未来 7 天
/** 全量重建提醒:先取消本应用全部代理提醒,再按当前数据重新发布 */
static async refreshReminders(context: common.Context): Promise<void> {
try {
await reminderAgentManager.cancelAllReminders();
const now: number = Date.now();
const rows: LocalEvent[] =
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
let published: number = 0;
for (const e of rows) {
if (published >= ReminderService.MAX_REMINDERS) {
break;
}
const remindAt: number = e.startTime - e.reminder * 60000;
if (remindAt <= now) {
continue; // 提醒时间已过
}
const d = new Date(remindAt);
const startTimeText: string = ReminderService.fmt(e.startTime);
const request: reminderAgentManager.ReminderRequestCalendar = {
reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,
dateTime: {
year: d.getFullYear(),
month: d.getMonth() + 1,
day: d.getDate(),
hour: d.getHours(),
minute: d.getMinutes(),
second: 0
},
title: '日程提醒',
content: `「${e.title}」将于 ${startTimeText} 开始(提前 ${e.reminder} 分钟)`,
notificationId: e.id % 100000,
expiredContent: '该日程已开始',
ringDuration: 5,
snoozeTimes: 0,
timeInterval: 0
};
await reminderAgentManager.publishReminder(request);
published++;
}
LogUtil.write(`提醒刷新完成:发布 ${published} 个提醒(窗口 7 天)`);
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`提醒刷新失败: ${e.code} - ${e.message}`);
}
}
private static fmt(ms: number): string {
const d = new Date(ms);
const p = (n: number): string => n < 10 ? '0' + n : String(n);
return `${d.getMonth() + 1}月${d.getDate()}日 ${p(d.getHours())}:${p(d.getMinutes())}`;
}
}
+239
View File
@@ -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();
}
}
+204
View File
@@ -0,0 +1,204 @@
// entry/src/main/ets/common/SyncEngine.ets
// 双向同步引擎:先推本地修改(PUT/DELETE),再拉远端变更(REPORT + etag 增量)
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount } from './AccountStore';
import { EventDb, LocalEvent, RemoteEvent } from './EventDb';
import { IcsUtil } from './IcsUtil';
import { DavClient, DavColorEntry } from './DavClient';
import { LogUtil } from './LogUtil';
export class SyncEngine {
/**
* 给异步操作加超时保护,防止网络挂起导致界面一直转圈
*/
static withTimeout<T>(task: Promise<T>, ms: number): Promise<T> {
return Promise.race<T>([
task,
new Promise<T>((_resolve: (value: T) => void, reject: (reason?: Error) => void) => {
setTimeout(() => reject(new Error(`同步超时(${Math.round(ms / 1000)}秒)`)), ms);
})
]);
}
/**
* 同步一个 CalDAV 账号(先推该账号的本地修改,再拉远端变更),
* 返回远端事件总数(拉取侧)。带 120 秒超时保护。
*/
static async syncAccount(context: common.UIAbilityContext, acc: DavAccount): Promise<number> {
const t0: number = Date.now();
LogUtil.write(`========== 同步账号「${acc.name}」开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`);
try {
const r: number = await SyncEngine.withTimeout<number>(
SyncEngine.syncAccountInner(context, acc), 120000);
LogUtil.write(`同步账号「${acc.name}」完成:拉取 ${r} 条日程,耗时 ${Math.round((Date.now() - t0) / 1000)} 秒`);
return r;
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`同步账号「${acc.name}」失败:${e.message}(耗时 ${Math.round((Date.now() - t0) / 1000)} 秒)`);
throw new Error(e.message !== '' ? e.message : `错误码 ${e.code}`);
}
}
private static async syncAccountInner(context: common.Context, acc: DavAccount): Promise<number> {
const auth: string = DavClient.authHeader(acc.username, acc.password);
// 0) 刷新服务器端日历本颜色(每次同步都校正)
await SyncEngine.refreshCalendarColors(acc, auth);
// 1) 推送该账号日历本下的本地修改
await SyncEngine.pushDirtyForAccount(context, acc, auth);
// 2) 拉取远端变更(全量 REPORT,etag 增量落库)
let changed: number = 0;
for (let i = 0; i < acc.calendarHrefs.length; i++) {
const href: string = acc.calendarHrefs[i];
const calKey: string = `${acc.id}_${i}`;
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
LogUtil.write(`日历本[${i}]「${calName}」开始同步:${href}`);
const t1: number = Date.now();
const items = await DavClient.reportCalendar(href, auth);
LogUtil.write(`日历本[${i}]「${calName}」REPORT 返回 ${items.length} 个资源`);
const remote: RemoteEvent[] = [];
let parseFail: number = 0;
let failSample: string = '';
for (const it of items) {
const parsed: RemoteEvent[] = IcsUtil.parse(it.ics);
if (parsed.length === 0) {
parseFail++;
if (failSample === '') {
failSample = it.ics.replace(/\r?\n/g, ' ⏎ ').substring(0, 600);
}
continue;
}
// 一个资源可能包含主事件 + 单次覆盖实例(RECURRENCE-ID),全部入库
for (const r of parsed) {
if (r.uid === '') {
r.uid = SyncEngine.uidFromHref(it.href);
}
r.etag = it.etag;
remote.push(r);
}
}
if (parseFail > 0) {
LogUtil.write(`日历本[${i}]「${calName}」有 ${parseFail} 个资源解析出 0 条日程,首个样本: ${failSample}`);
}
LogUtil.write(`日历本[${i}]「${calName}」解析出 ${remote.length} 条日程,开始落库`);
const stat: string = await EventDb.applyRemote(context, calKey, href, remote, false);
LogUtil.write(`日历本[${i}]「${calName}」日程落库完成:${stat},耗时 ${Math.round((Date.now() - t1) / 1000)} 秒`);
changed += remote.length;
// 3) 拉取该日历本下的待办(VTODO,只读展示,拉取失败不影响日程同步)
try {
const todoItems = await DavClient.reportTodos(href, auth);
const remoteTodos: RemoteEvent[] = [];
for (const it of todoItems) {
const parsedTodos: RemoteEvent[] = IcsUtil.parseTodos(it.ics);
for (const t of parsedTodos) {
if (t.uid === '') {
t.uid = SyncEngine.uidFromHref(it.href);
}
t.etag = it.etag;
remoteTodos.push(t);
}
}
const tstat: string = await EventDb.applyRemote(context, calKey, href, remoteTodos, true);
LogUtil.write(`日历本[${i}]「${calName}」待办:REPORT ${todoItems.length} 个资源,解析 ${remoteTodos.length} 条,${tstat}`);
} catch (err) {
const te = err as BusinessError;
LogUtil.write(`日历本[${i}]「${calName}」拉取待办失败(忽略)${te.message}`);
}
}
return changed;
}
/**
* 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors
* 失败静默(颜色不影响数据正确性)
*/
static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<void> {
try {
const entries: DavColorEntry[] = await DavClient.propfindColors(acc.serverUrl, auth);
if (entries.length === 0) {
return;
}
for (let i = 0; i < acc.calendarHrefs.length; i++) {
const target: string = acc.calendarHrefs[i];
const originMatch = /https?:\/\/[^/]+/i.exec(target);
let path: string = originMatch !== null ? target.substring(originMatch[0].length) : target;
if (path === '') {
path = '/';
}
const norm = (s: string): string => s.endsWith('/') ? s : s + '/';
const found = entries.find((e: DavColorEntry): boolean =>
norm(e.href) === norm(path));
if (found !== undefined && found.color !== '') {
while (acc.calendarColors.length <= i) {
acc.calendarColors.push('');
}
acc.calendarColors[i] = found.color;
}
}
} catch (err) {
const e = err as BusinessError;
console.info(`刷新日历本颜色失败(忽略): ${e.message}`);
}
}
/** 从资源 URL 提取 UID(解析失败时的兜底) */
private static uidFromHref(href: string): string {
const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
if (segs.length === 0) {
return String(Date.now());
}
const last: string = segs[segs.length - 1];
return last.endsWith('.ics') ? last.substring(0, last.length - 4) : last;
}
/**
* 推送指定账号日历本下的待同步事件(新建/修改 → PUT;删除 → DELETE
*/
static async pushDirtyForAccount(context: common.Context, acc: DavAccount, auth: string): Promise<void> {
const dirty: LocalEvent[] = await EventDb.getDirty(context);
const mine: LocalEvent[] = dirty.filter((e: LocalEvent): boolean => acc.calendarHrefs.includes(e.href));
LogUtil.write(`推送本地修改:全部待推送 ${dirty.length} 条,属于账号「${acc.name}」的 ${mine.length} 条`);
for (const e of mine) {
if (e.kind === 'todo') {
// 待办只读:本地不会有 dirty 待办,兜底清除
await EventDb.clearDirty(context, e.id, e.etag);
continue;
}
if (e.recurring) {
// 重复日程实例推送会破坏服务器整个序列,暂不支持
await EventDb.clearDirty(context, e.id, e.etag);
LogUtil.write(`推送跳过重复日程实例「${e.title}」(uid=${e.uid})`);
continue;
}
const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`;
if (e.deleted) {
await DavClient.deleteRemote(url, auth);
await EventDb.purge(context, e.id);
LogUtil.write(`推送删除「${e.title}」→ ${url}`);
} else {
const ics: string = IcsUtil.build(e);
const etag: string = await DavClient.putEvent(url, auth, ics);
await EventDb.clearDirty(context, e.id, etag);
LogUtil.write(`推送保存「${e.title}」→ ${url}${ics.length} 字节)`);
}
}
}
/**
* 清理本机虚拟日历(calKey=local)的待推送状态:
* 本机事件不参与 DAV 同步,直接落地
*/
static async settleLocalEvents(context: common.Context): Promise<void> {
const dirty: LocalEvent[] = await EventDb.getDirty(context);
for (const e of dirty) {
if (e.href !== '') {
continue;
}
if (e.deleted) {
await EventDb.purge(context, e.id);
} else {
await EventDb.clearDirty(context, e.id, '');
}
}
}
}
@@ -0,0 +1,43 @@
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
const DOMAIN = 0x0000;
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
}
onDestroy(): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy');
}
onWindowStageCreate(windowStage: window.WindowStage): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
return;
}
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
});
try {
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
} catch (err) {
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
}
}
onWindowStageDestroy(): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy');
}
onForeground(): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
}
onBackground(): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
}
}
@@ -0,0 +1,16 @@
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BackupExtensionAbility, BundleVersion } from '@kit.CoreFileKit';
const DOMAIN = 0x0000;
export default class EntryBackupAbility extends BackupExtensionAbility {
async onBackup() {
hilog.info(DOMAIN, 'testTag', 'onBackup ok');
await Promise.resolve();
}
async onRestore(bundleVersion: BundleVersion) {
hilog.info(DOMAIN, 'testTag', 'onRestore ok %{public}s', JSON.stringify(bundleVersion));
await Promise.resolve();
}
}
@@ -0,0 +1,53 @@
// entry/src/main/ets/entryformability/EntryFormAbility.ets
// 服务卡片 FormExtensionAbility:添加卡片时注入初始数据,定时/触发时更新
import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { CardDataService, CardData } from '../common/CardDataService';
import { LogUtil } from '../common/LogUtil';
export default class EntryFormAbility extends FormExtensionAbility {
/** 组装更新数据并推送到指定卡片 */
private pushData(formId: string): void {
CardDataService.buildCardData(this.context).then((data: CardData): void => {
const binding: formBindingData.FormBindingData =
formBindingData.createFormBindingData(data);
formProvider.updateForm(formId, binding).catch((err: BusinessError): void => {
LogUtil.write(`卡片 ${formId} 更新失败: ${err.message}`);
});
LogUtil.write(`卡片 ${formId} 数据已推送`);
}).catch((err: BusinessError): void => {
LogUtil.write(`卡片数据构建失败: ${err.message}`);
});
}
onAddForm(want: Want): formBindingData.FormBindingData {
LogUtil.init(this.context);
let formId: string = '';
if (want.parameters !== undefined) {
const raw = want.parameters['ohos.extra.param.key.form_identity'];
formId = typeof raw === 'string' ? raw : '';
}
LogUtil.write(`添加卡片: formId=${formId}`);
// 先返回空壳初始数据,异步查库后推送真实数据
if (formId !== '') {
CardDataService.registerForm(this.context, formId);
this.pushData(formId);
}
const initial = new CardData();
return formBindingData.createFormBindingData(initial);
}
onUpdateForm(formId: string): void {
LogUtil.init(this.context);
LogUtil.write(`系统触发卡片更新: formId=${formId}`);
CardDataService.registerForm(this.context, formId);
this.pushData(formId);
}
onRemoveForm(formId: string): void {
LogUtil.init(this.context);
LogUtil.write(`移除卡片: formId=${formId}`);
CardDataService.unregisterForm(this.context, formId);
}
}
+384
View File
@@ -0,0 +1,384 @@
// entry/src/main/ets/pages/AccountsPage.ets
// DAV 账号管理(由原 CalDAVSync 首页移植)
import { router } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore, TYPE_KEYS, TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV } from '../common/AccountStore';
import { SyncEngine } from '../common/SyncEngine';
import { EditNavParams } from './EditAccountPage';
import { EventDb } from '../common/EventDb';
import { LogUtil } from '../common/LogUtil';
@Entry
@Component
struct AccountsPage {
@State accounts: DavAccount[] = [];
@State showTypeMenu: boolean = false;
@State syncing: boolean = false;
@State syncingId: string = '';
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
LogUtil.init(ctx);
LogUtil.write('---- 打开 DAV 账号页 ----');
}
this.reloadAccounts();
}
onPageShow(): void {
this.reloadAccounts();
}
private async reloadAccounts(): Promise<void> {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
return;
}
this.accounts = await AccountStore.loadAll(context);
const pendingId: string | undefined = AppStorage.get<string>('pendingSyncAccountId');
if (pendingId !== undefined && pendingId !== '') {
AppStorage.setOrCreate<string>('pendingSyncAccountId', '');
const found = this.accounts.find((a: DavAccount): boolean => a.id === pendingId);
if (found !== undefined) {
this.syncSingleAccount(found);
}
}
}
private typeLabel(type: string): string {
if (type === TYPE_CALDAV) {
return 'CalDAV 日历';
}
if (type === TYPE_CARDDAV) {
return 'CardDAV 通讯录';
}
return 'WebDAV 文件';
}
private formatNow(): string {
const d = new Date();
const p = (n: number): string => n < 10 ? '0' + n : String(n);
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
private async syncSingleAccount(acc: DavAccount): Promise<void> {
if (this.syncing) {
return;
}
this.syncing = true;
this.syncingId = acc.id;
try {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
return;
}
if (acc.type === TYPE_CALDAV) {
await SyncEngine.withTimeout(
SyncEngine.syncAccount(context as common.UIAbilityContext, acc), 120000);
}
acc.itemCount = acc.calendarHrefs.length;
acc.lastSyncTime = this.formatNow();
await AccountStore.saveAll(context, this.accounts);
this.getUIContext().getPromptAction()
.showToast({ message: `「${acc.name}」同步完成` });
} catch (err) {
const e = err as BusinessError;
this.getUIContext().getPromptAction()
.showToast({ message: `「${acc.name}」同步失败:${e.message}` });
} finally {
this.syncing = false;
this.syncingId = '';
}
}
private openAddPage(type: string): void {
this.showTypeMenu = false;
AppStorage.setOrCreate<string>('pendingAccountType', type);
router.pushUrl({ url: 'pages/AddAccountPage' });
}
/** 点击账号 → 进入编辑页(查看/重选日历本) */
private openEditPage(acc: DavAccount): void {
if (acc.type !== TYPE_CALDAV) {
this.getUIContext().getPromptAction()
.showToast({ message: '该类型账号暂不支持编辑日历本' });
return;
}
AppStorage.setOrCreate<string>('pendingEditAccountId', acc.id);
const params = new EditNavParams();
params.accId = acc.id;
router.pushUrl({ url: 'pages/EditAccountPage', params: params });
}
/** 长按账号 → 弹出删除确认 */
private askDeleteAccount(acc: DavAccount): void {
this.getUIContext().showAlertDialog({
title: '删除账号',
message: `确定删除「${acc.name}」吗?\n\n该账号下的所有日历本、日程与待办的本地数据将一并删除。\n服务器上的数据不受影响。`,
autoCancel: true,
alignment: DialogAlignment.Center,
primaryButton: {
value: '取消',
action: (): void => {}
},
secondaryButton: {
value: '删除',
fontColor: $r('app.color.error'),
action: (): void => {
this.confirmDeleteAccount(acc);
}
}
});
}
/** 执行删除:账号配置 + 该账号全部本地日程/待办 */
private async confirmDeleteAccount(acc: DavAccount): Promise<void> {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
return;
}
try {
this.accounts = this.accounts.filter((a: DavAccount): boolean => a.id !== acc.id);
await AccountStore.saveAll(context, this.accounts);
await EventDb.deleteAccountEvents(context, acc.id);
this.getUIContext().getPromptAction()
.showToast({ message: `「${acc.name}」已删除` });
} catch (err) {
const e = err as BusinessError;
this.getUIContext().getPromptAction()
.showToast({ message: `删除失败:${e.message}` });
}
}
build() {
Stack({ alignContent: Alignment.BottomEnd }) {
Column() {
// 顶部
Row({ space: 10 }) {
Text('←')
.fontSize(20)
.fontColor($r('app.color.brand'))
.onClick(() => {
router.back();
})
Text('DAV 账号')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
}
.width('100%')
.padding({ left: 20, right: 20, top: 12, bottom: 8 })
if (this.accounts.length === 0) {
this.emptyState()
} else {
Scroll() {
Column({ space: 16 }) {
ForEach(TYPE_KEYS, (type: string) => {
if (this.accounts.some((a: DavAccount): boolean => a.type === type)) {
Column({ space: 8 }) {
Text(this.typeLabel(type))
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_secondary'))
ForEach(this.accounts.filter((a: DavAccount): boolean => a.type === type),
(acc: DavAccount) => {
AccountRow({
acc: acc,
isSyncing: this.syncingId === acc.id,
onSelect: (selected: DavAccount): void => {
this.openEditPage(selected);
},
onLongPress: (selected: DavAccount): void => {
this.askDeleteAccount(selected);
}
})
}, (acc: DavAccount) => acc.id)
}
.alignItems(HorizontalAlign.Start)
.width('100%')
}
}, (type: string) => type)
}
.width('100%')
.padding({ left: 20, right: 20, bottom: 100 })
.constraintSize({ minHeight: '100%' })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.Top)
}
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.page_bg'))
if (this.showTypeMenu) {
Column()
.width('100%')
.height('100%')
.onClick(() => {
this.showTypeMenu = false;
})
}
if (this.showTypeMenu) {
Column({ space: 10 }) {
this.menuItem('日', 'CalDAV', '日历同步', TYPE_CALDAV)
this.menuItem('人', 'CardDAV', '通讯录同步', TYPE_CARDDAV)
this.menuItem('文', 'WebDAV', '文件访问', TYPE_WEBDAV)
}
.width(220)
.padding(10)
.borderRadius(16)
.backgroundColor($r('app.color.card_bg'))
.shadow({ radius: 16, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 4 })
.margin({ right: 24, bottom: 156 })
}
Button() {
Text('')
.fontSize(26)
.fontColor($r('app.color.button_text'))
.fontWeight(FontWeight.Medium)
}
.width(56)
.height(56)
.borderRadius(28)
.backgroundColor($r('app.color.brand'))
.shadow({ radius: 8, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 2 })
.margin(24)
.onClick(() => {
this.showTypeMenu = !this.showTypeMenu;
})
}
.width('100%')
.height('100%')
}
@Builder
emptyState() {
Column({ space: 12 }) {
Text('')
.fontSize(30)
.fontColor($r('app.color.text_hint'))
.width(72)
.height(72)
.textAlign(TextAlign.Center)
.borderRadius(20)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1.5, color: $r('app.color.shadow_color') })
Text('还没有任何 DAV 账号')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text('点击右下角 + 添加 CalDAV / CardDAV / WebDAV 账号')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.textAlign(TextAlign.Center)
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
}
@Builder
menuItem(badge: string, title: string, desc: string, type: string) {
Row({ space: 12 }) {
Text(badge)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.brand'))
.width(40)
.height(40)
.textAlign(TextAlign.Center)
.borderRadius(10)
.backgroundColor($r('app.color.input_bg'))
Column({ space: 2 }) {
Text(title)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text(desc)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(8)
.borderRadius(10)
.onClick(() => {
this.openAddPage(type);
})
}
}
@Component
struct AccountRow {
@ObjectLink acc: DavAccount;
@Prop isSyncing: boolean = false;
onSelect: (acc: DavAccount) => void = (selected: DavAccount): void => {};
onLongPress: (acc: DavAccount) => void = (selected: DavAccount): void => {};
build() {
Row({ space: 12 }) {
Text(this.acc.name !== '' ? this.acc.name.substring(0, 1) : 'D')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.button_text'))
.width(40)
.height(40)
.textAlign(TextAlign.Center)
.borderRadius(10)
.backgroundColor($r('app.color.brand'))
Column({ space: 4 }) {
Text(this.acc.name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.acc.serverUrl)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.acc.lastSyncTime === ''
? '尚未同步'
: `上次同步 ${this.acc.lastSyncTime} · ${this.acc.calendarHrefs.length} 个日历本`)
.fontSize(12)
.fontColor($r('app.color.text_hint'))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
if (this.isSyncing) {
LoadingProgress()
.width(20)
.height(20)
.color($r('app.color.brand'))
} else {
Text('')
.fontSize(20)
.fontColor($r('app.color.text_hint'))
}
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
.onClick(() => {
this.onSelect(this.acc);
})
.gesture(LongPressGesture({ repeat: false })
.onAction((event: GestureEvent) => {
this.onLongPress(this.acc);
}))
}
}
+283
View File
@@ -0,0 +1,283 @@
// entry/src/main/ets/pages/AddAccountPage.ets
// 添加账号第一页:URL / 用户名 / 密码 → 连接(凭据经 AppStorage 传给日历本选择页)
import { http } from '@kit.NetworkKit';
import { router } from '@kit.ArkUI';
import { buffer } from '@kit.ArkTS';
import url from '@ohos.url';
import { BusinessError } from '@kit.BasicServicesKit';
import { TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV } from '../common/AccountStore';
@Entry
@Component
struct AddAccountPage {
@State serverUrl: string = '';
@State username: string = '';
@State password: string = '';
@State isLoading: boolean = false;
@State statusMsg: string = '';
@State statusOk: boolean = false;
@State typeLabel: string = 'CalDAV';
private accountType: string = TYPE_CALDAV;
aboutToAppear(): void {
const t: string | undefined = AppStorage.get<string>('pendingAccountType');
this.accountType = (t === undefined || t === '') ? TYPE_CALDAV : t;
if (this.accountType === TYPE_CARDDAV) {
this.typeLabel = 'CardDAV';
} else if (this.accountType === TYPE_WEBDAV) {
this.typeLabel = 'WebDAV';
} else {
this.typeLabel = 'CalDAV';
}
}
private normalizeUrl(): string | null {
let rawUrl: string = this.serverUrl.trim();
if (rawUrl === '') {
return null;
}
if (!rawUrl.startsWith('http://') && !rawUrl.startsWith('https://')) {
rawUrl = 'https://' + rawUrl;
}
const hostPattern: RegExp =
/^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$|^\d{1,3}(\.\d{1,3}){3}$|^\[[0-9A-Fa-f:]+\]$|^localhost$/;
try {
const parsed = url.URL.parseURL(rawUrl);
const hostname: string = parsed.hostname !== '' ? parsed.hostname : parsed.host;
if (hostname !== '' && hostPattern.test(hostname)) {
return rawUrl;
}
this.statusMsg = `URL 主机名无效:${rawUrl}`;
this.statusOk = false;
return null;
} catch (err) {
const e = err as BusinessError;
console.error(`URL 解析异常(${e.code}),使用正则兜底: ${e.message}`);
const fallbackPattern: RegExp =
/^https?:\/\/[^\s/:?#]+(:\d{1,5})?([/?#][^\s]*)?$/;
if (fallbackPattern.test(rawUrl)) {
return rawUrl;
}
this.statusMsg = `URL 解析失败:${e.message}`;
this.statusOk = false;
return null;
}
}
private encodeBasicAuth(): string {
try {
return buffer.from(`${this.username}:${this.password}`).toString('base64');
} catch (err) {
const e = err as BusinessError;
console.error(`Base64 编码失败: ${e.message}`);
return '';
}
}
private async sendOnce(serverUrl: string, method: http.RequestMethod, authHeader: string): Promise<number> {
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(serverUrl, {
method: method,
header: {
'Authorization': authHeader,
'Accept': '*/*',
'User-Agent': 'SyncCalendar/1.0'
},
connectTimeout: 10000,
readTimeout: 10000
});
return resp.responseCode;
} finally {
httpRequest.destroy();
}
}
private async probeServer(serverUrl: string): Promise<boolean> {
const token: string = this.encodeBasicAuth();
if (token === '') {
this.statusMsg = '凭据编码失败:请在真机或模拟器上运行';
this.statusOk = false;
return false;
}
const authHeader: string = 'Basic ' + token;
try {
let code: number = await this.sendOnce(serverUrl, http.RequestMethod.OPTIONS, authHeader);
if (code === 401) {
code = await this.sendOnce(serverUrl, http.RequestMethod.GET, authHeader);
}
if (code === 401) {
this.statusMsg = '服务器拒绝凭据(401),请检查用户名密码';
this.statusOk = false;
return false;
}
if (code >= 200 && code < 500) {
this.statusMsg = '服务器连接成功';
this.statusOk = true;
return true;
}
this.statusMsg = `服务器返回异常状态码:${code}`;
this.statusOk = false;
return false;
} catch (err) {
const e = err as BusinessError;
console.error(`连接失败: ${e.code} - ${e.message}`);
this.statusMsg = `无法连接服务器:${e.message}`;
this.statusOk = false;
return false;
}
}
private async onConnectAndSave(): Promise<void> {
if (this.isLoading) {
return;
}
const targetUrl: string | null = this.normalizeUrl();
if (targetUrl === null) {
if (this.statusMsg === '' || this.statusMsg === '正在连接服务器…' || this.statusOk) {
this.statusMsg = '请输入有效的服务器地址';
}
this.statusOk = false;
return;
}
if (!this.username.trim()) {
this.statusMsg = '请输入用户名';
this.statusOk = false;
return;
}
if (!this.password) {
this.statusMsg = '请输入密码';
this.statusOk = false;
return;
}
this.isLoading = true;
this.statusMsg = '正在连接服务器…';
this.statusOk = false;
const ok: boolean = await this.probeServer(targetUrl);
if (ok) {
AppStorage.setOrCreate<string>('pendingDavUrl', targetUrl);
AppStorage.setOrCreate<string>('pendingDavUsername', this.username.trim());
AppStorage.setOrCreate<string>('pendingDavPassword', this.password);
this.getUIContext().getPromptAction().showToast({ message: '连接成功' });
router.pushUrl({ url: 'pages/CalendarListPage' });
}
this.isLoading = false;
}
build() {
Column({ space: 24 }) {
Row({ space: 6 }) {
Text('←')
.fontSize(18)
.fontColor($r('app.color.brand'))
Text('返回')
.fontSize(16)
.fontColor($r('app.color.brand'))
}
.width('100%')
.onClick(() => {
router.back();
})
Column({ space: 8 }) {
Text(`添加${this.typeLabel}账号`)
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text('输入服务器账号信息')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.width('100%')
Column({ space: 16 }) {
this.formField('服务器地址', '例如:https://nas.example.com/caldav/', this.serverUrl,
(value: string) => {
this.serverUrl = value;
}, false)
this.formField('用户名', '请输入用户名', this.username,
(value: string) => {
this.username = value;
}, false)
this.formField('密码', '请输入密码', this.password,
(value: string) => {
this.password = value;
}, true)
}
.width('100%')
.padding(20)
.borderRadius(16)
.backgroundColor($r('app.color.card_bg'))
.shadow({ radius: 12, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 4 })
Button() {
Row({ space: 8 }) {
if (this.isLoading) {
LoadingProgress()
.width(20)
.height(20)
.color($r('app.color.button_text'))
}
Text(this.isLoading ? '连接中…' : '连接并保存')
.fontSize(17)
.fontColor($r('app.color.button_text'))
.fontWeight(FontWeight.Medium)
}
}
.width('100%')
.height(48)
.borderRadius(24)
.backgroundColor(this.isLoading ? $r('app.color.brand_disabled') : $r('app.color.brand'))
.enabled(!this.isLoading)
.onClick(() => {
this.onConnectAndSave();
})
if (this.statusMsg) {
Row({ space: 6 }) {
Text(this.statusOk ? '✓' : '✕')
.fontSize(14)
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
Text(this.statusMsg)
.fontSize(14)
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
}
.width('100%')
.padding(12)
.borderRadius(8)
.backgroundColor(this.statusOk ? $r('app.color.success_bg') : $r('app.color.error_bg'))
}
Blank()
}
.width('100%')
.height('100%')
.padding({ left: 24, right: 24, top: 16, bottom: 24 })
.backgroundColor($r('app.color.page_bg'))
.alignItems(HorizontalAlign.Center)
}
@Builder
formField(label: string, placeholder: string, value: string,
onChange: (value: string) => void, isPassword: boolean) {
Column({ space: 8 }) {
Text(label)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.fontWeight(FontWeight.Medium)
TextInput({ text: value, placeholder: placeholder })
.type(isPassword ? InputType.Password : InputType.Normal)
.showPasswordIcon(isPassword)
.height(44)
.fontSize(15)
.backgroundColor($r('app.color.input_bg'))
.borderRadius(8)
.onChange(onChange)
}
.alignItems(HorizontalAlign.Start)
.width('100%')
}
}
@@ -0,0 +1,459 @@
// entry/src/main/ets/pages/CalendarListPage.ets
// 添加账号第二页:PROPFIND 列出日历本 → 勾选 → 命名 → 保存账号
import { http } from '@kit.NetworkKit';
import { router } from '@kit.ArkUI';
import { buffer } from '@kit.ArkTS';
import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore';
import { LogUtil } from '../common/LogUtil';
/**
* 日历本条目(@Observed 使勾选状态变化能刷新 UI)
*/
@Observed
export class CalendarItem {
href: string;
name: string;
color: string; // 服务器定义的颜色(calendar-color),可能为空
selected: boolean;
constructor(href: string, name: string, color: string) {
this.href = href;
this.name = name;
this.color = color;
this.selected = false;
}
}
@Entry
@Component
struct CalendarListPage {
@State calendarList: CalendarItem[] = [];
@State accountName: string = '';
@State isLoading: boolean = true;
@State statusMsg: string = '正在获取日历列表…';
@State statusOk: boolean = false;
@State isSaving: boolean = false;
@State selectedCount: number = 0;
@State allSelected: boolean = false;
private serverUrl: string = '';
private username: string = '';
private password: string = '';
aboutToAppear(): Promise<void> {
return this.initPage();
}
private async initPage(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
LogUtil.init(ctx);
}
this.serverUrl = AppStorage.get<string>('pendingDavUrl') ?? '';
this.username = AppStorage.get<string>('pendingDavUsername') ?? '';
this.password = AppStorage.get<string>('pendingDavPassword') ?? '';
LogUtil.write(`添加账号流程开始:服务器=${this.serverUrl} 用户名=${this.username}`);
if (this.serverUrl === '') {
this.isLoading = false;
this.statusMsg = '尚未连接服务器,请先返回重新连接';
this.statusOk = false;
return;
}
await this.fetchCalendars();
}
private encodeBasicAuth(): string {
try {
return buffer.from(`${this.username}:${this.password}`).toString('base64');
} catch (err) {
const e = err as BusinessError;
console.error(`Base64 编码失败: ${e.message}`);
return '';
}
}
private async fetchCalendars(): Promise<void> {
const token: string = this.encodeBasicAuth();
if (token === '') {
this.isLoading = false;
this.statusMsg = '凭据编码失败:请在真机或模拟器上运行';
this.statusOk = false;
return;
}
const requestBody: string = '<?xml version="1.0" encoding="utf-8"?>' +
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
'<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
'</d:prop></d:propfind>';
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(this.serverUrl, {
method: 'PROPFIND' as http.RequestMethod,
header: {
'Authorization': 'Basic ' + token,
'Content-Type': 'application/xml; charset=utf-8',
'Depth': '1',
'User-Agent': 'SyncCalendar/1.0'
},
extraData: requestBody,
connectTimeout: 10000,
readTimeout: 15000
});
console.info(`PROPFIND 响应码: ${resp.responseCode}`);
if (resp.responseCode === 401) {
this.isLoading = false;
this.statusMsg = '登录已失效,请返回重新连接';
this.statusOk = false;
return;
}
if (resp.responseCode !== 207 && resp.responseCode !== 200) {
this.isLoading = false;
this.statusMsg = `获取日历列表失败,服务器返回:${resp.responseCode}`;
this.statusOk = false;
return;
}
const xml: string = resp.result as string;
LogUtil.write(`添加账号 PROPFIND → ${resp.responseCode},响应体 ${xml.length} 字符`);
const list: CalendarItem[] = this.parseCalendarList(xml);
for (const item of list) {
LogUtil.write(`发现日历本:「${item.name}」${item.href} 颜色=${item.color === '' ? '(无)' : item.color}`);
}
this.isLoading = false;
if (list.length === 0) {
this.statusMsg = '该路径下未发现日历本(没有包含 calendar 资源类型的集合)';
this.statusOk = false;
} else {
this.calendarList = list;
this.updateSelectionState();
this.statusMsg = `发现 ${list.length} 个日历本,请勾选要同步的日历`;
this.statusOk = true;
}
} catch (err) {
const e = err as BusinessError;
console.error(`获取日历列表失败: ${e.code} - ${e.message}`);
this.isLoading = false;
this.statusMsg = `获取日历列表失败:${e.message}`;
this.statusOk = false;
} finally {
httpRequest.destroy();
}
}
private extractTag(xml: string, tag: string): string {
const pattern: string = '<([\\w-]+:)?' + tag + '(?:\\s[^>]*)?>([\\s\\S]*?)<\\/([\\w-]+:)?' + tag + '>';
const regex: RegExp = new RegExp(pattern, 'i');
const match = regex.exec(xml);
return match !== null ? match[2].trim() : '';
}
private parseCalendarList(xml: string): CalendarItem[] {
const items: CalendarItem[] = [];
const originMatch = /https?:\/\/[^/]+/i.exec(this.serverUrl);
const origin: string = originMatch !== null ? originMatch[0] : '';
const blocks: string[] = xml.split(/<\/[\w-]*:?response>/i);
for (const block of blocks) {
if (!/<[\w-]*:?response[\s>]/i.test(block)) {
continue;
}
const href: string = this.extractTag(block, 'href');
if (href === '') {
continue;
}
const resourcetype: string = this.extractTag(block, 'resourcetype');
if (!/calendar/i.test(resourcetype)) {
continue;
}
let name: string = this.extractTag(block, 'displayname');
if (name === '') {
const segs: string[] = href.split('/').filter((s: string) => s !== '');
if (segs.length > 0) {
try {
name = decodeURIComponent(segs[segs.length - 1]);
} catch (err) {
name = segs[segs.length - 1];
}
} else {
name = href;
}
}
// 服务器端颜色:cs:getcolor 或 ical:calendar-color,带 Alpha 时转成 #RRGGBB
let color: string = AccountStore.normalizeColor(this.extractTag(block, 'getcolor'));
if (color === '') {
color = AccountStore.normalizeColor(this.extractTag(block, 'calendar-color'));
}
const fullHref: string = href.startsWith('http') ? href : origin + href;
items.push(new CalendarItem(fullHref, name, color));
}
return items;
}
private async saveSelection(): Promise<void> {
if (this.isSaving) {
return;
}
if (this.accountName.trim() === '') {
this.statusMsg = '请先给这个日历账户起一个名字';
this.statusOk = false;
return;
}
const selectedItems: CalendarItem[] = this.calendarList.filter((c: CalendarItem) => c.selected);
if (selectedItems.length === 0) {
this.statusMsg = '请至少勾选一个日历本';
this.statusOk = false;
return;
}
this.isSaving = true;
try {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
this.statusMsg = '无法获取应用上下文';
this.statusOk = false;
this.isSaving = false;
return;
}
const acc = new DavAccount();
acc.id = String(Date.now());
const accType: string | undefined = AppStorage.get<string>('pendingAccountType');
acc.type = (accType === undefined || accType === '') ? TYPE_CALDAV : accType;
acc.name = this.accountName.trim();
acc.serverUrl = this.serverUrl;
acc.username = this.username;
acc.password = this.password;
acc.calendarHrefs = selectedItems.map((c: CalendarItem): string => c.href);
acc.calendarNames = selectedItems.map((c: CalendarItem): string => c.name);
acc.calendarColors = selectedItems.map((c: CalendarItem): string => c.color);
LogUtil.write(`保存账号「${acc.name}」:id=${acc.id},勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
await AccountStore.addAccount(context, acc);
AppStorage.setOrCreate<string>('pendingSyncAccountId', acc.id);
this.getUIContext().getPromptAction()
.showToast({ message: `账号已保存,共 ${selectedItems.length} 个日历本` });
this.statusMsg = '保存成功';
this.statusOk = true;
router.back({ url: 'pages/AccountsPage' });
} catch (err) {
const e = err as BusinessError;
console.error(`保存失败: ${e.code} - ${e.message}`);
this.statusMsg = `保存失败:${e.message}`;
this.statusOk = false;
}
this.isSaving = false;
}
private updateSelectionState(): void {
const count: number = this.calendarList.filter((c: CalendarItem): boolean => c.selected).length;
this.selectedCount = count;
this.allSelected = this.calendarList.length > 0 && count === this.calendarList.length;
}
private handleItemToggle(item: CalendarItem): void {
item.selected = !item.selected;
this.updateSelectionState();
}
private toggleAll(): void {
const target: boolean = !this.allSelected;
this.calendarList.forEach((c: CalendarItem) => {
c.selected = target;
});
this.updateSelectionState();
}
build() {
Column({ space: 14 }) {
Row({ space: 6 }) {
Text('←')
.fontSize(18)
.fontColor($r('app.color.brand'))
Text('返回')
.fontSize(16)
.fontColor($r('app.color.brand'))
}
.width('100%')
.onClick(() => {
router.back();
})
Column({ space: 6 }) {
Text('选择日历本')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text('勾选需要同步的日历本,并为账户命名')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.width('100%')
Column({ space: 8 }) {
Row({ space: 4 }) {
Text('日历账户名称')
.fontSize(15)
.fontColor($r('app.color.text_primary'))
.fontWeight(FontWeight.Medium)
Text('*')
.fontSize(15)
.fontColor($r('app.color.error'))
}
TextInput({ text: this.accountName, placeholder: '例如:我的群晖日历' })
.height(46)
.fontSize(16)
.fontColor($r('app.color.text_primary'))
.placeholderColor($r('app.color.text_hint'))
.backgroundColor($r('app.color.input_bg'))
.borderRadius(10)
.border({ width: 1.5, color: $r('app.color.brand') })
.padding({ left: 12, right: 12 })
.onChange((value: string) => {
this.accountName = value;
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.borderRadius(14)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
Row({ space: 8 }) {
Text(this.calendarList.length > 0
? `已选 ${this.selectedCount} / ${this.calendarList.length}` : ' ')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
Blank()
if (this.calendarList.length > 0) {
Button(this.allSelected ? '取消全选' : '全选')
.fontSize(13)
.fontColor($r('app.color.brand'))
.backgroundColor(Color.Transparent)
.border({ width: 1, color: $r('app.color.brand'), radius: 14 })
.height(30)
.padding({ left: 14, right: 14 })
.onClick(() => {
this.toggleAll();
})
}
}
.width('100%')
Scroll() {
Column({ space: 4 }) {
if (this.isLoading) {
Column({ space: 12 }) {
LoadingProgress()
.width(36)
.height(36)
.color($r('app.color.brand'))
Text('正在从服务器获取日历列表…')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding(32)
} else if (this.calendarList.length === 0) {
Text(this.statusMsg)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(24)
.textAlign(TextAlign.Center)
} else {
ForEach(this.calendarList, (item: CalendarItem) => {
CalendarRow({
item: item,
onSelect: (selectedItem: CalendarItem): void => {
this.handleItemToggle(selectedItem);
}
})
}, (item: CalendarItem) => item.href)
}
}
.width('100%')
.padding(8)
.constraintSize({ minHeight: '100%' })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.Top)
.borderRadius(16)
.backgroundColor($r('app.color.card_bg'))
.shadow({ radius: 12, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 4 })
if (this.statusMsg !== '' && !this.isLoading) {
Row({ space: 6 }) {
Text(this.statusOk ? '✓' : '✕')
.fontSize(14)
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
Text(this.statusMsg)
.fontSize(14)
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
}
.width('100%')
.padding(12)
.borderRadius(8)
.backgroundColor(this.statusOk ? $r('app.color.success_bg') : $r('app.color.error_bg'))
}
Button() {
Text(this.isSaving ? '保存中…' : '保存并完成')
.fontSize(17)
.fontColor($r('app.color.button_text'))
.fontWeight(FontWeight.Medium)
}
.width('100%')
.height(48)
.borderRadius(24)
.backgroundColor(this.isSaving ? $r('app.color.brand_disabled') : $r('app.color.brand'))
.enabled(!this.isSaving && !this.isLoading)
.onClick(() => {
this.saveSelection();
})
}
.width('100%')
.height('100%')
.padding({ left: 24, right: 24, top: 16, bottom: 24 })
.backgroundColor($r('app.color.page_bg'))
.alignItems(HorizontalAlign.Center)
}
}
@Component
struct CalendarRow {
@ObjectLink item: CalendarItem;
onSelect: (item: CalendarItem) => void = (selectedItem: CalendarItem): void => {};
build() {
Row({ space: 12 }) {
Text(this.item.name)
.fontSize(16)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Blank()
if (this.item.selected) {
Text('✓')
.fontSize(14)
.fontColor($r('app.color.button_text'))
.width(24)
.height(24)
.textAlign(TextAlign.Center)
.borderRadius(12)
.backgroundColor($r('app.color.brand'))
} else {
Text('')
.width(24)
.height(24)
.borderRadius(12)
.border({ width: 1.5, color: $r('app.color.text_hint') })
}
}
.width('100%')
.padding({ left: 12, right: 12, top: 14, bottom: 14 })
.borderRadius(8)
.onClick(() => {
this.onSelect(this.item);
})
}
}
@@ -0,0 +1,530 @@
// entry/src/main/ets/pages/EditAccountPage.ets
// 编辑账号:查看/重选该账号下的日历本、修改账户名
// 保存后清理失效日历本的本地数据,并触发一次重新同步
import { http } from '@kit.NetworkKit';
import { router } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { buffer } from '@kit.ArkTS';
import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore } from '../common/AccountStore';
import { EventDb } from '../common/EventDb';
import { LogUtil } from '../common/LogUtil';
/**
* 日历本条目(@Observed 使勾选状态变化能刷新 UI)
*/
@Observed
export class EditCalendarItem {
href: string;
name: string;
color: string; // 服务器定义的颜色(calendar-color),可能为空
selected: boolean;
constructor(href: string, name: string, color: string) {
this.href = href;
this.name = name;
this.color = color;
this.selected = false;
}
}
/**
* 路由参数(AppStorage 的兜底通道)
*/
export class EditNavParams {
accId: string = '';
}
@Entry
@Component
struct EditAccountPage {
@State accountName: string = '';
@State serverUrl: string = '';
@State username: string = '';
@State calendarList: EditCalendarItem[] = [];
@State isLoading: boolean = true;
@State statusMsg: string = '正在获取日历列表…';
@State statusOk: boolean = false;
@State isSaving: boolean = false;
@State selectedCount: number = 0;
@State allSelected: boolean = false;
private acc: DavAccount = new DavAccount();
private found: boolean = false;
aboutToAppear(): Promise<void> {
return this.initPage();
}
private async initPage(): Promise<void> {
try {
// 1) 读取目标账号 idAppStorage 为主,路由参数兜底)
let accId: string | undefined = AppStorage.get<string>('pendingEditAccountId');
if (accId === undefined || accId === '') {
const rawParams: Object | undefined = router.getParams();
if (rawParams instanceof EditNavParams && rawParams.accId !== '') {
accId = rawParams.accId;
}
}
console.info(`编辑账号 initPage: accId=${accId ?? '(undefined)'}`);
if (accId === undefined || accId === '') {
this.isLoading = false;
this.statusMsg = '未指定要编辑的账号,请返回账号列表重新点击';
this.statusOk = false;
return;
}
// 2) 获取应用上下文(getHostContext 过早调用可能为 undefinedgetContext 兜底)
let context: common.Context | undefined = undefined;
try {
context = this.getUIContext().getHostContext();
} catch (err) {
console.info('getHostContext 异常,使用 getContext 兜底');
}
if (context === undefined) {
context = getContext(this);
}
if (context === undefined) {
this.isLoading = false;
this.statusMsg = '无法获取应用上下文';
this.statusOk = false;
return;
}
// 3) 从账号列表里找到该账号
const accounts: DavAccount[] = await AccountStore.loadAll(context);
const foundAcc = accounts.find((a: DavAccount): boolean => a.id === accId);
if (foundAcc === undefined) {
this.isLoading = false;
this.statusMsg = `账号不存在(id=${accId}),可能已被删除`;
this.statusOk = false;
return;
}
this.acc = foundAcc;
this.found = true;
this.accountName = foundAcc.name;
this.serverUrl = foundAcc.serverUrl;
this.username = foundAcc.username;
LogUtil.write(`编辑账号「${foundAcc.name}」:id=${foundAcc.id},当前已选 ${foundAcc.calendarHrefs.length} 个日历本`);
for (let i = 0; i < foundAcc.calendarHrefs.length; i++) {
const nm: string = i < foundAcc.calendarNames.length ? foundAcc.calendarNames[i] : '';
LogUtil.write(` 已选日历本[${i}]「${nm}」${foundAcc.calendarHrefs[i]}`);
}
await this.fetchCalendars();
} catch (err) {
const e = err as BusinessError;
console.error(`编辑账号初始化失败: ${e.code} - ${e.message}`);
this.isLoading = false;
this.statusMsg = `页面初始化失败:${e.message}`;
this.statusOk = false;
}
}
private encodeBasicAuth(): string {
try {
return buffer.from(`${this.acc.username}:${this.acc.password}`).toString('base64');
} catch (err) {
const e = err as BusinessError;
console.error(`Base64 编码失败: ${e.message}`);
return '';
}
}
private async fetchCalendars(): Promise<void> {
const token: string = this.encodeBasicAuth();
if (token === '') {
this.isLoading = false;
this.statusMsg = '凭据编码失败:请在真机或模拟器上运行';
this.statusOk = false;
return;
}
const requestBody: string = '<?xml version="1.0" encoding="utf-8"?>' +
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
'<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
'</d:prop></d:propfind>';
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(this.acc.serverUrl, {
method: 'PROPFIND' as http.RequestMethod,
header: {
'Authorization': 'Basic ' + token,
'Content-Type': 'application/xml; charset=utf-8',
'Depth': '1',
'User-Agent': 'SyncCalendar/1.0'
},
extraData: requestBody,
connectTimeout: 10000,
readTimeout: 15000
});
console.info(`编辑账号 PROPFIND 响应码: ${resp.responseCode}`);
if (resp.responseCode === 401) {
this.isLoading = false;
this.statusMsg = '登录已失效,请检查账号密码';
this.statusOk = false;
return;
}
if (resp.responseCode !== 207 && resp.responseCode !== 200) {
this.isLoading = false;
this.statusMsg = `获取日历列表失败,服务器返回:${resp.responseCode}`;
this.statusOk = false;
return;
}
const xml: string = resp.result as string;
LogUtil.write(`编辑账号 PROPFIND → ${resp.responseCode},响应体 ${xml.length} 字符`);
const list: EditCalendarItem[] = this.parseCalendarList(xml);
this.isLoading = false;
if (list.length === 0) {
LogUtil.write('编辑账号:未发现任何日历本');
this.statusMsg = '该路径下未发现日历本';
this.statusOk = false;
} else {
// 已勾选的日历本按账号当前配置预选
for (const item of list) {
item.selected = this.acc.calendarHrefs.includes(item.href);
LogUtil.write(`编辑账号发现日历本:「${item.name}」${item.href} 颜色=${item.color === '' ? '(无)' : item.color} 预选=${item.selected}`);
}
this.calendarList = list;
this.updateSelectionState();
this.statusMsg = `该账号共 ${list.length} 个日历本,当前已选 ${this.selectedCount} 个`;
this.statusOk = true;
}
} catch (err) {
const e = err as BusinessError;
console.error(`获取日历列表失败: ${e.code} - ${e.message}`);
this.isLoading = false;
this.statusMsg = `获取日历列表失败:${e.message}`;
this.statusOk = false;
} finally {
httpRequest.destroy();
}
}
private extractTag(xml: string, tag: string): string {
const pattern: string = '<([\\w-]+:)?' + tag + '(?:\\s[^>]*)?>([\\s\\S]*?)<\\/([\\w-]+:)?' + tag + '>';
const regex: RegExp = new RegExp(pattern, 'i');
const match = regex.exec(xml);
return match !== null ? match[2].trim() : '';
}
private parseCalendarList(xml: string): EditCalendarItem[] {
const items: EditCalendarItem[] = [];
const originMatch = /https?:\/\/[^/]+/i.exec(this.acc.serverUrl);
const origin: string = originMatch !== null ? originMatch[0] : '';
const blocks: string[] = xml.split(/<\/[\w-]*:?response>/i);
for (const block of blocks) {
if (!/<[\w-]*:?response[\s>]/i.test(block)) {
continue;
}
const href: string = this.extractTag(block, 'href');
if (href === '') {
continue;
}
const resourcetype: string = this.extractTag(block, 'resourcetype');
if (!/calendar/i.test(resourcetype)) {
continue;
}
let name: string = this.extractTag(block, 'displayname');
if (name === '') {
const segs: string[] = href.split('/').filter((s: string) => s !== '');
if (segs.length > 0) {
try {
name = decodeURIComponent(segs[segs.length - 1]);
} catch (err) {
name = segs[segs.length - 1];
}
} else {
name = href;
}
}
let color: string = AccountStore.normalizeColor(this.extractTag(block, 'getcolor'));
if (color === '') {
color = AccountStore.normalizeColor(this.extractTag(block, 'calendar-color'));
}
const fullHref: string = href.startsWith('http') ? href : origin + href;
items.push(new EditCalendarItem(fullHref, name, color));
}
return items;
}
/** 保存:更新账号的日历本选择与名称,清理失效数据,触发重新同步 */
private async saveSelection(): Promise<void> {
if (this.isSaving || !this.found) {
return;
}
if (this.accountName.trim() === '') {
this.statusMsg = '账户名不能为空';
this.statusOk = false;
return;
}
const selectedItems: EditCalendarItem[] = this.calendarList.filter((c: EditCalendarItem) => c.selected);
if (selectedItems.length === 0) {
this.statusMsg = '请至少勾选一个日历本';
this.statusOk = false;
return;
}
this.isSaving = true;
try {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
this.statusMsg = '无法获取应用上下文';
this.statusOk = false;
this.isSaving = false;
return;
}
const accounts: DavAccount[] = await AccountStore.loadAll(context);
const target = accounts.find((a: DavAccount): boolean => a.id === this.acc.id);
if (target === undefined) {
this.statusMsg = '账号不存在,可能已被删除';
this.statusOk = false;
this.isSaving = false;
return;
}
target.name = this.accountName.trim();
target.calendarHrefs = selectedItems.map((c: EditCalendarItem): string => c.href);
target.calendarNames = selectedItems.map((c: EditCalendarItem): string => c.name);
target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color);
LogUtil.write(`编辑账号保存:「${target.name}」id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
await AccountStore.saveAll(context, accounts);
// 重选后 calKey(accId_序号)会变化,清理已取消勾选的日历本数据
const validKeys: string[] =
selectedItems.map((c: EditCalendarItem, i: number): string => `${target.id}_${i}`);
await EventDb.pruneAccountEvents(context, target.id, validKeys);
AppStorage.setOrCreate<string>('pendingSyncAccountId', target.id);
this.getUIContext().getPromptAction()
.showToast({ message: `已保存,同步 ${selectedItems.length} 个日历本` });
router.back();
} catch (err) {
const e = err as BusinessError;
console.error(`保存失败: ${e.code} - ${e.message}`);
this.statusMsg = `保存失败:${e.message}`;
this.statusOk = false;
}
this.isSaving = false;
}
private updateSelectionState(): void {
const count: number = this.calendarList.filter((c: EditCalendarItem): boolean => c.selected).length;
this.selectedCount = count;
this.allSelected = this.calendarList.length > 0 && count === this.calendarList.length;
}
private handleItemToggle(item: EditCalendarItem): void {
// 选中状态已在 EditCalendarRow 内部直接翻转,这里只刷新计数
this.updateSelectionState();
}
private toggleAll(): void {
const target: boolean = !this.allSelected;
this.calendarList.forEach((c: EditCalendarItem) => {
c.selected = target;
});
this.updateSelectionState();
}
build() {
Column({ space: 14 }) {
Row({ space: 6 }) {
Text('←')
.fontSize(18)
.fontColor($r('app.color.brand'))
Text('返回')
.fontSize(16)
.fontColor($r('app.color.brand'))
}
.width('100%')
.onClick(() => {
router.back();
})
Column({ space: 6 }) {
Text('编辑账号')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(this.username !== '' ? `${this.serverUrl} · ${this.username}` : this.serverUrl)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.width('100%')
Column({ space: 8 }) {
Row({ space: 4 }) {
Text('日历账户名称')
.fontSize(15)
.fontColor($r('app.color.text_primary'))
.fontWeight(FontWeight.Medium)
Text('*')
.fontSize(15)
.fontColor($r('app.color.error'))
}
TextInput({ text: this.accountName, placeholder: '例如:我的群晖日历' })
.height(46)
.fontSize(16)
.fontColor($r('app.color.text_primary'))
.placeholderColor($r('app.color.text_hint'))
.backgroundColor($r('app.color.input_bg'))
.borderRadius(10)
.border({ width: 1.5, color: $r('app.color.brand') })
.padding({ left: 12, right: 12 })
.onChange((value: string) => {
this.accountName = value;
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.borderRadius(14)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
Row({ space: 8 }) {
Text(this.calendarList.length > 0
? `已选 ${this.selectedCount} / ${this.calendarList.length}` : ' ')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
Blank()
if (this.calendarList.length > 0) {
Button(this.allSelected ? '取消全选' : '全选')
.fontSize(13)
.fontColor($r('app.color.brand'))
.backgroundColor(Color.Transparent)
.border({ width: 1, color: $r('app.color.brand'), radius: 14 })
.height(30)
.padding({ left: 14, right: 14 })
.onClick(() => {
this.toggleAll();
})
}
}
.width('100%')
Scroll() {
Column({ space: 4 }) {
if (this.isLoading) {
Column({ space: 12 }) {
LoadingProgress()
.width(36)
.height(36)
.color($r('app.color.brand'))
Text('正在从服务器获取日历列表…')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding(32)
} else if (this.calendarList.length === 0) {
Text(this.statusMsg)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(24)
.textAlign(TextAlign.Center)
} else {
ForEach(this.calendarList, (item: EditCalendarItem) => {
EditCalendarRow({
item: item,
onSelect: (selectedItem: EditCalendarItem): void => {
this.handleItemToggle(selectedItem);
}
})
}, (item: EditCalendarItem) => `${item.href}_${item.selected}`)
}
}
.width('100%')
.padding(8)
.constraintSize({ minHeight: '100%' })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.Top)
.borderRadius(16)
.backgroundColor($r('app.color.card_bg'))
.shadow({ radius: 12, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 4 })
if (this.statusMsg !== '' && !this.isLoading) {
Row({ space: 6 }) {
Text(this.statusOk ? '✓' : '✕')
.fontSize(14)
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
Text(this.statusMsg)
.fontSize(14)
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
}
.width('100%')
.padding(12)
.borderRadius(8)
.backgroundColor(this.statusOk ? $r('app.color.success_bg') : $r('app.color.error_bg'))
}
Button() {
Text(this.isSaving ? '保存中…' : '保存并同步')
.fontSize(17)
.fontColor($r('app.color.button_text'))
.fontWeight(FontWeight.Medium)
}
.width('100%')
.height(48)
.borderRadius(24)
.backgroundColor(this.isSaving ? $r('app.color.brand_disabled') : $r('app.color.brand'))
.enabled(!this.isSaving && !this.isLoading && this.found)
.onClick(() => {
this.saveSelection();
})
}
.width('100%')
.height('100%')
.padding({ left: 24, right: 24, top: 16, bottom: 24 })
.backgroundColor($r('app.color.page_bg'))
.alignItems(HorizontalAlign.Center)
}
}
@Component
struct EditCalendarRow {
@ObjectLink item: EditCalendarItem;
onSelect: (item: EditCalendarItem) => void = (selectedItem: EditCalendarItem): void => {};
build() {
Row({ space: 12 }) {
if (this.item.color !== '') {
Circle()
.width(12)
.height(12)
.fill(this.item.color)
}
Text(this.item.name)
.fontSize(16)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Blank()
if (this.item.selected) {
Text('✓')
.fontSize(14)
.fontColor($r('app.color.button_text'))
.width(24)
.height(24)
.textAlign(TextAlign.Center)
.borderRadius(12)
.backgroundColor($r('app.color.brand'))
} else {
Text('')
.width(24)
.height(24)
.borderRadius(12)
.border({ width: 1.5, color: $r('app.color.text_hint') })
}
}
.width('100%')
.padding({ left: 12, right: 12, top: 14, bottom: 14 })
.borderRadius(8)
.onClick(() => {
// 直接翻转 @ObjectLink 属性,子组件自身即可触发刷新
this.item.selected = !this.item.selected;
this.onSelect(this.item);
})
}
}
+496
View File
@@ -0,0 +1,496 @@
// entry/src/main/ets/pages/EventEditPage.ets
// 日程编辑页:新建 / 修改 / 删除本地(DAV 或本机)日程
import { router } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore, CalSource, BookPalette } from '../common/AccountStore';
import { EventDb, LocalEvent } from '../common/EventDb';
import { DavClient } from '../common/DavClient';
import { SyncEngine } from '../common/SyncEngine';
/** 可选的日历本 */
class BookChoice {
calKey: string = '';
href: string = '';
name: string = '';
color: string = '#007DFF';
}
@Entry
@Component
struct EventEditPage {
@State title: string = '';
@State location: string = '';
@State description: string = '';
@State allDay: boolean = false;
@State startMs: number = 0;
@State endMs: number = 0;
@State books: BookChoice[] = [];
@State chosenKey: string = '';
@State isSaving: boolean = false;
@State statusMsg: string = '';
@State isExisting: boolean = false;
private event: LocalEvent | null = null;
aboutToAppear(): Promise<void> {
return this.initPage();
}
private async initPage(): Promise<void> {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
return;
}
// 收集可写入的日历本(DAV + 本机)
const sources: CalSourceWithHref[] = await CalendarDataBridge.loadWritableSources(context);
const choices: BookChoice[] = [];
for (const s of sources) {
const b = new BookChoice();
b.calKey = s.calKey;
b.href = s.href;
b.name = s.name;
b.color = s.color;
choices.push(b);
}
this.books = choices;
// 编辑既有事件
const pendingId: number | undefined = AppStorage.get<number>('pendingEventId');
if (pendingId !== undefined && pendingId > 0) {
const loaded = await EventDb.getById(context, pendingId);
if (loaded !== null) {
this.event = loaded;
this.isExisting = true;
this.title = loaded.title;
this.location = loaded.location;
this.description = loaded.description;
this.allDay = loaded.isAllDay;
this.startMs = loaded.startTime;
this.endMs = loaded.endTime;
this.chosenKey = loaded.calKey;
return;
}
}
// 新建:默认时间 = 所选日期 9:00-10:00
const base: number = AppStorage.get<number>('pendingEventDate') ?? Date.now();
const dayStart = new Date(new Date(base).getFullYear(), new Date(base).getMonth(),
new Date(base).getDate()).getTime();
this.startMs = dayStart + 9 * 3600000;
this.endMs = dayStart + 10 * 3600000;
if (choices.length > 0) {
this.chosenKey = choices[0].calKey;
}
}
private chosenBook(): BookChoice | null {
return this.books.find((b: BookChoice): boolean => b.calKey === this.chosenKey) ?? null;
}
private fmtDate(ms: number): string {
const d = new Date(ms);
const p = (n: number): string => n < 10 ? '0' + n : String(n);
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
private fmtTime(ms: number): string {
const d = new Date(ms);
const p = (n: number): string => n < 10 ? '0' + n : String(n);
return `${p(d.getHours())}:${p(d.getMinutes())}`;
}
private pickStartDate(): void {
const cur = new Date(this.startMs);
DatePickerDialog.show({
start: new Date(2000, 0, 1),
end: new Date(2050, 11, 31),
selected: cur,
onDateAccept: (value: Date) => {
const keep = new Date(this.startMs);
const newStart: number = new Date(value.getFullYear(), value.getMonth(), value.getDate(),
keep.getHours(), keep.getMinutes()).getTime();
const dur: number = this.endMs - this.startMs;
this.startMs = newStart;
this.endMs = this.allDay ? newStart + 86399999 : newStart + dur;
}
});
}
private pickStartTime(): void {
const cur = new Date(this.startMs);
TimePickerDialog.show({
selected: cur,
onAccept: (value: TimePickerResult) => {
const d = new Date(this.startMs);
const newStart: number = new Date(d.getFullYear(), d.getMonth(), d.getDate(),
value.hour, value.minute).getTime();
const dur: number = this.endMs - this.startMs;
this.startMs = newStart;
this.endMs = newStart + dur;
}
});
}
private pickEndDate(): void {
const cur = new Date(this.endMs);
DatePickerDialog.show({
start: new Date(2000, 0, 1),
end: new Date(2050, 11, 31),
selected: cur,
onDateAccept: (value: Date) => {
const keep = new Date(this.endMs);
this.endMs = new Date(value.getFullYear(), value.getMonth(), value.getDate(),
keep.getHours(), keep.getMinutes()).getTime();
}
});
}
private pickEndTime(): void {
const cur = new Date(this.endMs);
TimePickerDialog.show({
selected: cur,
onAccept: (value: TimePickerResult) => {
const d = new Date(this.endMs);
this.endMs = new Date(d.getFullYear(), d.getMonth(), d.getDate(),
value.hour, value.minute).getTime();
}
});
}
private toggleAllDay(): void {
this.allDay = !this.allDay;
if (this.allDay) {
const s = new Date(this.startMs);
const dayStart: number = new Date(s.getFullYear(), s.getMonth(), s.getDate()).getTime();
this.startMs = dayStart;
this.endMs = dayStart + 86399999;
} else {
const s = new Date(this.startMs);
this.startMs = s.getTime() + 9 * 3600000;
this.endMs = this.startMs + 3600000;
}
}
private validate(): boolean {
if (this.title.trim() === '') {
this.statusMsg = '请输入日程标题';
return false;
}
if (this.endMs < this.startMs) {
this.statusMsg = '结束时间不能早于开始时间';
return false;
}
return true;
}
private async save(): Promise<void> {
if (this.isSaving || !this.validate()) {
return;
}
if (this.event !== null && this.event.recurring) {
this.statusMsg = '重复日程(系列中的一天)暂不支持修改,请到服务器端调整重复规则';
return;
}
this.isSaving = true;
this.statusMsg = '';
try {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
return;
}
const book = this.chosenBook();
const e = this.event ?? new LocalEvent();
const isNew: boolean = this.event === null;
e.title = this.title.trim();
e.location = this.location.trim();
e.description = this.description.trim();
e.startTime = this.startMs;
e.endTime = this.allDay ? this.startMs + 86399999 : this.endMs;
e.isAllDay = this.allDay;
e.calKey = book !== null ? book.calKey : 'local';
e.href = book !== null ? book.href : '';
if (isNew) {
e.uid = `syncal-${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
e.remotePath = encodeURIComponent(e.uid) + '.ics';
await EventDb.insertLocal(context, e);
} else {
await EventDb.updateLocal(context, e);
}
// 立即推送(尽力而为,失败不打断,下次同步会再推)
if (e.href !== '') {
const accounts: DavAccount[] = await AccountStore.loadAll(context);
const acc = accounts.find((a: DavAccount): boolean => a.calendarHrefs.includes(e.href));
if (acc !== undefined) {
const auth: string = DavClient.authHeader(acc.username, acc.password);
await SyncEngine.pushDirtyForAccount(context, acc, auth);
}
} else {
await SyncEngine.settleLocalEvents(context);
}
this.getUIContext().getPromptAction().showToast({ message: '日程已保存' });
router.back();
} catch (err) {
const ex = err as BusinessError;
console.error(`保存日程失败: ${ex.message}`);
this.statusMsg = `保存失败:${ex.message}(已保存到本地,稍后同步会重试)`;
// 数据仍在本地且带 dirty 标记,不会丢
router.back();
}
this.isSaving = false;
}
private async removeEvent(): Promise<void> {
if (this.event === null || this.isSaving) {
return;
}
if (this.event.recurring) {
this.statusMsg = '重复日程(系列中的一天)暂不支持删除,请到服务器端调整重复规则';
return;
}
this.isSaving = true;
try {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
return;
}
await EventDb.markDeleted(context, this.event.id);
if (this.event.href !== '') {
const accounts: DavAccount[] = await AccountStore.loadAll(context);
const acc = accounts.find((a: DavAccount): boolean => a.calendarHrefs.includes(this.event?.href ?? ''));
if (acc !== undefined) {
const auth: string = DavClient.authHeader(acc.username, acc.password);
await SyncEngine.pushDirtyForAccount(context, acc, auth);
}
} else {
await SyncEngine.settleLocalEvents(context);
}
this.getUIContext().getPromptAction().showToast({ message: '日程已删除' });
router.back();
} catch (err) {
const ex = err as BusinessError;
this.statusMsg = `删除失败:${ex.message}`;
}
this.isSaving = false;
}
build() {
Column({ space: 14 }) {
// 顶部
Row({ space: 6 }) {
Text('取消')
.fontSize(16)
.fontColor($r('app.color.brand'))
.onClick(() => {
router.back();
})
Blank()
Text(this.isExisting ? '编辑日程' : '新建日程')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text('保存')
.fontSize(16)
.fontColor(this.isSaving ? $r('app.color.text_hint') : $r('app.color.brand'))
.fontWeight(FontWeight.Medium)
.onClick(() => {
this.save();
})
}
.width('100%')
Scroll() {
Column({ space: 14 }) {
// 标题
TextInput({ text: this.title, placeholder: '标题' })
.height(46)
.fontSize(16)
.backgroundColor($r('app.color.card_bg'))
.borderRadius(12)
.onChange((v: string) => {
this.title = v;
})
// 全天
Row() {
Text('全天')
.fontSize(15)
.fontColor($r('app.color.text_primary'))
Blank()
Toggle({ type: ToggleType.Switch, isOn: this.allDay })
.selectedColor($r('app.color.brand'))
.onChange(() => {
this.toggleAllDay();
})
}
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
// 时间卡片
Column({ space: 10 }) {
this.timeRow('开始', true)
Divider().color($r('app.color.shadow_color'))
this.timeRow('结束', false)
}
.width('100%')
.padding(6)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
// 日历本选择
Column({ space: 8 }) {
Text('日历本')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
ForEach(this.books, (b: BookChoice) => {
Row({ space: 5 }) {
Circle().width(8).height(8).fill(b.color)
Text(b.name)
.fontSize(12)
.fontColor(this.chosenKey === b.calKey
? $r('app.color.button_text') : $r('app.color.text_primary'))
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.borderRadius(14)
.margin({ right: 8, bottom: 8 })
.backgroundColor(this.chosenKey === b.calKey ? b.color : $r('app.color.chip_off_bg'))
.onClick(() => {
this.chosenKey = b.calKey;
})
}, (b: BookChoice) => b.calKey)
}
.width('100%')
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
// 地点
TextInput({ text: this.location, placeholder: '地点(可选)' })
.height(44)
.fontSize(14)
.backgroundColor($r('app.color.card_bg'))
.borderRadius(12)
.onChange((v: string) => {
this.location = v;
})
// 描述
TextArea({ text: this.description, placeholder: '备注(可选)' })
.height(90)
.fontSize(14)
.backgroundColor($r('app.color.card_bg'))
.borderRadius(12)
.onChange((v: string) => {
this.description = v;
})
if (this.statusMsg !== '') {
Text(this.statusMsg)
.fontSize(13)
.fontColor($r('app.color.error'))
.width('100%')
}
// 删除
if (this.isExisting) {
Button('删除日程')
.fontSize(15)
.fontColor($r('app.color.error'))
.backgroundColor($r('app.color.error_bg'))
.width('100%')
.height(44)
.borderRadius(12)
.enabled(!this.isSaving)
.onClick(() => {
this.removeEvent();
})
}
}
.width('100%')
.padding({ left: 20, right: 20, bottom: 30 })
.constraintSize({ minHeight: '100%' })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.Top)
}
.width('100%')
.height('100%')
.padding({ top: 12 })
.backgroundColor($r('app.color.page_bg'))
}
@Builder
timeRow(label: string, isStart: boolean) {
Row({ space: 8 }) {
Text(label)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(36)
Text(this.fmtDate(isStart ? this.startMs : this.endMs))
.fontSize(15)
.fontColor($r('app.color.brand'))
.onClick(() => {
if (isStart) {
this.pickStartDate();
} else {
this.pickEndDate();
}
})
if (!this.allDay) {
Text(this.fmtTime(isStart ? this.startMs : this.endMs))
.fontSize(15)
.fontColor($r('app.color.brand'))
.onClick(() => {
if (isStart) {
this.pickStartTime();
} else {
this.pickEndTime();
}
})
}
Blank()
}
.width('100%')
.padding({ left: 8, right: 8, top: 8, bottom: 8 })
}
}
/** 桥接:从账号存储拿可写来源(DAV 日历本 + 本机),附上 href */
class CalendarDataBridge {
static async loadWritableSources(context: common.Context): Promise<CalSourceWithHref[]> {
const result: CalSourceWithHref[] = [];
const accounts: DavAccount[] = await AccountStore.loadAll(context);
for (const acc of accounts) {
for (let i = 0; i < acc.calendarHrefs.length; i++) {
const s = new CalSourceWithHref();
s.calKey = `${acc.id}_${i}`;
let name: string = i < acc.calendarNames.length ? acc.calendarNames[i] : '';
if (name === '') {
name = acc.calendarHrefs.length === 1 ? acc.name : `日历本 ${i + 1}`;
}
s.name = `${acc.name} · ${name}`;
let color: string = i < acc.calendarColors.length ? AccountStore.normalizeColor(acc.calendarColors[i]) : '';
s.color = color !== '' ? color : BookPalette.colorFor(i);
s.href = acc.calendarHrefs[i];
result.push(s);
}
}
const local = new CalSourceWithHref();
local.calKey = 'local';
local.name = '本机(不同步)';
local.color = '#5A6068';
result.push(local);
return result;
}
}
class CalSourceWithHref extends CalSource {
href: string = '';
}
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
// entry/src/main/ets/pages/SettingsPage.ets
// 设置页:系统日历混合显示开关 + 自动同步间隔
import { router } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { AppSettings } from '../common/AppSettings';
import { LogUtil } from '../common/LogUtil';
const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60];
@Entry
@Component
struct SettingsPage {
@State showSystem: boolean = true;
@State intervalMinutes: number = 1;
private context?: common.Context;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
this.context = ctx;
LogUtil.init(ctx);
AppSettings.getShowSystemCalendar(ctx).then((v: boolean): void => {
this.showSystem = v;
});
AppSettings.getSyncIntervalMinutes(ctx).then((v: number): void => {
this.intervalMinutes = v;
});
}
private async saveShowSystem(value: boolean): Promise<void> {
if (this.context === undefined) {
return;
}
await AppSettings.setShowSystemCalendar(this.context, value);
this.getUIContext().getPromptAction()
.showToast({ message: value ? '已开启系统日历混合显示,返回首页生效' : '已关闭系统日历混合显示,返回首页生效' });
}
private async saveInterval(minutes: number): Promise<void> {
if (this.context === undefined) {
return;
}
await AppSettings.setSyncIntervalMinutes(this.context, minutes);
AppStorage.setOrCreate('syncIntervalMinutes', minutes);
this.getUIContext().getPromptAction()
.showToast({ message: `自动同步间隔已设为 ${minutes} 分钟` });
}
private intervalLabel(minutes: number): string {
return minutes >= 60 ? `${minutes / 60} 小时` : `${minutes} 分钟`;
}
build() {
Column() {
// 顶部
Row({ space: 10 }) {
Text('←')
.fontSize(20)
.fontColor($r('app.color.brand'))
.onClick(() => {
router.back();
})
Text('设置')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
}
.width('100%')
.padding({ left: 20, right: 20, top: 12, bottom: 8 })
Scroll() {
Column({ space: 12 }) {
// 系统日历混合显示
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('混合显示系统日历')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text('关闭后只显示 DAV 账号的日程')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: this.showSystem })
.selectedColor($r('app.color.brand'))
.onChange((isOn: boolean) => {
if (isOn !== this.showSystem) {
this.showSystem = isOn;
this.saveShowSystem(isOn);
}
})
}
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
// 自动同步间隔
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('自动同步间隔')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text('应用打开期间按此间隔自动同步')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Select([{ value: '1 分钟' }, { value: '5 分钟' }, { value: '15 分钟' },
{ value: '30 分钟' }, { value: '1 小时' }] as SelectOption[])
.selected(INTERVAL_OPTIONS.indexOf(this.intervalMinutes))
.value(this.intervalLabel(this.intervalMinutes))
.fontColor($r('app.color.text_primary'))
.font({ size: 14 })
.optionFont({ size: 14 })
.selectedOptionFont({ size: 14 })
.onSelect((index: number) => {
if (index >= 0 && index < INTERVAL_OPTIONS.length) {
this.intervalMinutes = INTERVAL_OPTIONS[index];
this.saveInterval(INTERVAL_OPTIONS[index]);
}
})
}
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
}
.width('100%')
.padding({ left: 20, right: 20, top: 8, bottom: 20 })
}
.layoutWeight(1)
.align(Alignment.Top)
.scrollBar(BarState.Off)
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.page_bg'))
}
}
@@ -0,0 +1,113 @@
// entry/src/main/ets/pages/widget/Widget2x2.ets
// 2x2 服务卡片:日期 + 农历 + 下一条日程
let storage2x2 = new LocalStorage();
class CardItem2x2 {
title: string = '';
time: string = '';
endTime: string = '';
date: string = '';
showDate: boolean = false;
calName: string = '';
color: string = '#007DFF';
}
@Entry(storage2x2)
@Component
struct Widget2x2Card {
@LocalStorageProp('eventsJson') eventsJson: string = '[]';
@LocalStorageProp('dateText') dateText: string = '';
@LocalStorageProp('lunarText') lunarText: string = '';
private parseItems(): CardItem2x2[] {
try {
return JSON.parse(this.eventsJson) as CardItem2x2[];
} catch (err) {
return [];
}
}
build() {
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(this.dateText)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
.maxLines(1)
Blank()
Text(this.lunarText)
.fontSize(11)
.fontColor('#8A8A8A')
}
.width('100%')
Divider()
.strokeWidth(0.5)
.color('#E5E5E5')
if (this.parseItems().length === 0) {
Column({ space: 4 }) {
Text('暂无日程')
.fontSize(13)
.fontColor('#8A8A8A')
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else {
Column({ space: 4 }) {
Row({ space: 6 }) {
Circle().width(6).height(6).fill(this.parseItems()[0].color)
Text(this.parseItems()[0].title)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#1A1A1A')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (this.parseItems()[0].calName !== '') {
Text(this.parseItems()[0].calName)
.fontSize(9)
.fontColor(this.parseItems()[0].color)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '30%' })
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
Blank()
Row({ space: 6 }) {
Text(this.parseItems()[0].date)
.fontSize(10)
.fontColor('#8A8A8A')
Text(this.parseItems()[0].time === '全天'
? '全天'
: `${this.parseItems()[0].time} - ${this.parseItems()[0].endTime}`)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor('#007DFF')
}
.width('100%')
.justifyContent(FlexAlign.End)
}
.width('100%')
.layoutWeight(1)
}
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: {}
});
})
}
}
@@ -0,0 +1,184 @@
// entry/src/main/ets/pages/widget/Widget4x2.ets
// 2x4 服务卡片:日期 + 农历 + 未来几条日程(时间轴样式,按天分组)
let storage2x4 = new LocalStorage();
class CardItem2x4 {
title: string = '';
time: string = '';
endTime: string = '';
date: string = '';
showDate: boolean = false;
calName: string = '';
color: string = '#007DFF';
}
@Entry(storage2x4)
@Component
struct Widget4x2Card {
@LocalStorageProp('eventsJson') eventsJson: string = '[]';
@LocalStorageProp('dateText') dateText: string = '';
@LocalStorageProp('lunarText') lunarText: string = '';
private parseItems(): CardItem2x4[] {
try {
const all: CardItem2x4[] = JSON.parse(this.eventsJson) as CardItem2x4[];
return all.slice(0, 4);
} catch (err) {
return [];
}
}
/** 全天事件行:圆角矩形 + 左侧日历色竖条 + 标题 + “全天”标记(无时间轴) */
@Builder
buildAllDayRow(item: CardItem2x4) {
Row({ space: 8 }) {
Column()
.width(3)
.height(16)
.borderRadius(2)
.backgroundColor(item.color)
Text(item.title)
.fontSize(12)
.fontColor('#1A1A1A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text('全天')
.fontSize(9)
.fontColor('#FFFFFF')
.backgroundColor(item.color)
.borderRadius(6)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
if (item.calName !== '') {
Text(item.calName)
.fontSize(9)
.fontColor(item.color)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '25%' })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(8)
.backgroundColor('#F5F7FA')
}
/** 有时间事件行(时间轴):圆角矩形 + 左色竖条 + 开始时间(上)-竖线-结束时间(下) + 标题 */
@Builder
buildTimedRow(item: CardItem2x4) {
Row({ space: 8 }) {
Column()
.width(3)
.height(36)
.borderRadius(2)
.backgroundColor(item.color)
// 时间列:开始时间在上、结束时间在下、中间竖线连接
Column({ space: 2 }) {
Text(item.time)
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Column()
.width(1.5)
.layoutWeight(1)
.backgroundColor('#D8D8D8')
.borderRadius(1)
Text(item.endTime)
.fontSize(10)
.fontColor('#999999')
}
.width(38)
.alignItems(HorizontalAlign.Center)
.height(36)
Text(item.title)
.fontSize(12)
.fontColor('#1A1A1A')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (item.calName !== '') {
Text(item.calName)
.fontSize(9)
.fontColor(item.color)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '25%' })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(8)
.backgroundColor('#F5F7FA')
}
build() {
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(this.dateText)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
.maxLines(1)
Text(this.lunarText)
.fontSize(11)
.fontColor('#8A8A8A')
.maxLines(1)
}
.width('100%')
Divider().strokeWidth(0.5).color('#E5E5E5')
if (this.parseItems().length === 0) {
Column() {
Text('暂无日程')
.fontSize(13)
.fontColor('#8A8A8A')
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else {
List({ space: 4 }) {
ForEach(this.parseItems(), (item: CardItem2x4, idx: number) => {
ListItem() {
Column({ space: 3 }) {
if (item.showDate) {
Text(item.date)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#666666')
.width('100%')
}
if (item.time === '全天') {
this.buildAllDayRow(item)
} else {
this.buildTimedRow(item)
}
}
.width('100%')
}
}, (item: CardItem2x4, idx: number) => `${idx}_${item.title}_${item.time}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
.cachedCount(4)
}
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: {}
});
})
}
}
@@ -0,0 +1,187 @@
// entry/src/main/ets/pages/widget/Widget4x4.ets
// 4x4 服务卡片:日期 + 农历 + 从今天开始的日程(时间轴样式,按天分组,可滑动)
let storage4x4 = new LocalStorage();
class CardItem4x4 {
title: string = '';
time: string = '';
endTime: string = '';
date: string = '';
showDate: boolean = false;
calName: string = '';
color: string = '#007DFF';
}
@Entry(storage4x4)
@Component
struct Widget4x4Card {
@LocalStorageProp('eventsJson') eventsJson: string = '[]';
@LocalStorageProp('dateText') dateText: string = '';
@LocalStorageProp('lunarText') lunarText: string = '';
private parseItems(): CardItem4x4[] {
try {
return JSON.parse(this.eventsJson) as CardItem4x4[];
} catch (err) {
return [];
}
}
/** 全天事件行:圆角矩形 + 左侧日历色竖条 + 标题 + “全天”标记(无时间轴) */
@Builder
buildAllDayRow(item: CardItem4x4) {
Row({ space: 8 }) {
Column()
.width(3)
.height(16)
.borderRadius(2)
.backgroundColor(item.color)
Text(item.title)
.fontSize(12)
.fontColor('#1A1A1A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text('全天')
.fontSize(9)
.fontColor('#FFFFFF')
.backgroundColor(item.color)
.borderRadius(6)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
if (item.calName !== '') {
Text(item.calName)
.fontSize(9)
.fontColor(item.color)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '25%' })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(8)
.backgroundColor('#F5F7FA')
}
/** 有时间事件行(时间轴):圆角矩形 + 左色竖条 + 开始时间(上)-竖线-结束时间(下) + 标题 */
@Builder
buildTimedRow(item: CardItem4x4) {
Row({ space: 8 }) {
Column()
.width(3)
.height(38)
.borderRadius(2)
.backgroundColor(item.color)
// 时间列:开始时间在上、结束时间在下、中间竖线连接
Column({ space: 2 }) {
Text(item.time)
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Column()
.width(1.5)
.layoutWeight(1)
.backgroundColor('#D8D8D8')
.borderRadius(1)
Text(item.endTime)
.fontSize(10)
.fontColor('#999999')
}
.width(38)
.alignItems(HorizontalAlign.Center)
.height(38)
Text(item.title)
.fontSize(12)
.fontColor('#1A1A1A')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (item.calName !== '') {
Text(item.calName)
.fontSize(9)
.fontColor(item.color)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '25%' })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(8)
.backgroundColor('#F5F7FA')
}
build() {
Column({ space: 6 }) {
Row({ space: 6 }) {
Text(this.dateText)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
Text(this.lunarText)
.fontSize(12)
.fontColor('#8A8A8A')
Blank()
Text('同步日历')
.fontSize(10)
.fontColor('#B0B0B0')
}
.width('100%')
Divider().strokeWidth(0.5).color('#E5E5E5')
if (this.parseItems().length === 0) {
Column({ space: 6 }) {
Text('📅')
.fontSize(24)
Text('暂无日程')
.fontSize(13)
.fontColor('#8A8A8A')
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else {
List({ space: 4 }) {
ForEach(this.parseItems(), (item: CardItem4x4, idx: number) => {
ListItem() {
Column({ space: 3 }) {
if (item.showDate) {
Text(item.date)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#666666')
.width('100%')
}
if (item.time === '全天') {
this.buildAllDayRow(item)
} else {
this.buildTimedRow(item)
}
}
.width('100%')
}
}, (item: CardItem4x4, idx: number) => `${idx}_${item.title}_${item.time}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.cachedCount(8)
}
}
.width('100%')
.height('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: {}
});
})
}
}
@@ -0,0 +1,187 @@
// entry/src/main/ets/pages/widget/Widget6x4.ets
// 6x4 服务卡片:日期 + 农历 + 从今天开始的日程(时间轴样式,比 4x4 显示更多)
let storage6x4 = new LocalStorage();
class CardItem6x4 {
title: string = '';
time: string = '';
endTime: string = '';
date: string = '';
showDate: boolean = false;
calName: string = '';
color: string = '#007DFF';
}
@Entry(storage6x4)
@Component
struct Widget6x4Card {
@LocalStorageProp('eventsJson') eventsJson: string = '[]';
@LocalStorageProp('dateText') dateText: string = '';
@LocalStorageProp('lunarText') lunarText: string = '';
private parseItems(): CardItem6x4[] {
try {
return JSON.parse(this.eventsJson) as CardItem6x4[];
} catch (err) {
return [];
}
}
/** 全天事件行:圆角矩形 + 左侧日历色竖条 + 标题 + “全天”标记(无时间轴) */
@Builder
buildAllDayRow(item: CardItem6x4) {
Row({ space: 8 }) {
Column()
.width(3)
.height(16)
.borderRadius(2)
.backgroundColor(item.color)
Text(item.title)
.fontSize(12)
.fontColor('#1A1A1A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text('全天')
.fontSize(9)
.fontColor('#FFFFFF')
.backgroundColor(item.color)
.borderRadius(6)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
if (item.calName !== '') {
Text(item.calName)
.fontSize(9)
.fontColor(item.color)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '25%' })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(8)
.backgroundColor('#F5F7FA')
}
/** 有时间事件行(时间轴):圆角矩形 + 左色竖条 + 开始时间(上)-竖线-结束时间(下) + 标题 */
@Builder
buildTimedRow(item: CardItem6x4) {
Row({ space: 8 }) {
Column()
.width(3)
.height(38)
.borderRadius(2)
.backgroundColor(item.color)
// 时间列:开始时间在上、结束时间在下、中间竖线连接
Column({ space: 2 }) {
Text(item.time)
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Column()
.width(1.5)
.layoutWeight(1)
.backgroundColor('#D8D8D8')
.borderRadius(1)
Text(item.endTime)
.fontSize(10)
.fontColor('#999999')
}
.width(38)
.alignItems(HorizontalAlign.Center)
.height(38)
Text(item.title)
.fontSize(12)
.fontColor('#1A1A1A')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (item.calName !== '') {
Text(item.calName)
.fontSize(9)
.fontColor(item.color)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '25%' })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(8)
.backgroundColor('#F5F7FA')
}
build() {
Column({ space: 6 }) {
Row({ space: 6 }) {
Text(this.dateText)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
Text(this.lunarText)
.fontSize(12)
.fontColor('#8A8A8A')
Blank()
Text('同步日历')
.fontSize(10)
.fontColor('#B0B0B0')
}
.width('100%')
Divider().strokeWidth(0.5).color('#E5E5E5')
if (this.parseItems().length === 0) {
Column({ space: 6 }) {
Text('📅')
.fontSize(24)
Text('暂无日程')
.fontSize(13)
.fontColor('#8A8A8A')
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else {
List({ space: 4 }) {
ForEach(this.parseItems(), (item: CardItem6x4, idx: number) => {
ListItem() {
Column({ space: 3 }) {
if (item.showDate) {
Text(item.date)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#666666')
.width('100%')
}
if (item.time === '全天') {
this.buildAllDayRow(item)
} else {
this.buildTimedRow(item)
}
}
.width('100%')
}
}, (item: CardItem6x4, idx: number) => `${idx}_${item.title}_${item.time}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.cachedCount(12)
}
}
.width('100%')
.height('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: {}
});
})
}
}
+101
View File
@@ -0,0 +1,101 @@
{
"module": {
"name": "entry",
"type": "entry",
"description": "$string:module_desc",
"mainElement": "EntryAbility",
"deviceTypes": [
"phone"
],
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
},
{
"name": "ohos.permission.PUBLISH_AGENT_REMINDER"
},
{
"name": "ohos.permission.READ_CALENDAR",
"reason": "$string:perm_read_calendar",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{
"name": "ohos.permission.WRITE_CALENDAR",
"reason": "$string:perm_write_calendar",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{
"name": "ohos.permission.READ_WHOLE_CALENDAR",
"reason": "$string:perm_read_whole_calendar",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
}
],
"deliveryWithInstall": true,
"installationFree": false,
"pages": "$profile:main_pages",
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"description": "$string:EntryAbility_desc",
"icon": "$media:layered_image",
"label": "$string:EntryAbility_label",
"startWindowIcon": "$media:startIcon",
"startWindowBackground": "$color:start_window_background",
"exported": true,
"skills": [
{
"entities": [
"entity.system.home"
],
"actions": [
"ohos.want.action.home"
]
}
]
}
],
"extensionAbilities": [
{
"name": "EntryBackupAbility",
"srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets",
"type": "backup",
"exported": false,
"metadata": [
{
"name": "ohos.extension.backup",
"resource": "$profile:backup_config"
}
],
},
{
"name": "EntryFormAbility",
"srcEntry": "./ets/entryformability/EntryFormAbility.ets",
"description": "$string:card_ability_desc",
"type": "form",
"exported": true,
"metadata": [
{
"name": "ohos.extension.form",
"resource": "$profile:form_config"
}
]
}
]
},
}
@@ -0,0 +1,23 @@
{
"color": [
{ "name": "start_window_background", "value": "#FFFFFF" },
{ "name": "page_bg", "value": "#F1F3F5" },
{ "name": "card_bg", "value": "#FFFFFF" },
{ "name": "input_bg", "value": "#F1F3F5" },
{ "name": "text_primary", "value": "#182431" },
{ "name": "text_secondary", "value": "#66000000" },
{ "name": "text_hint", "value": "#4D000000" },
{ "name": "brand", "value": "#007DFF" },
{ "name": "brand_disabled", "value": "#99007DFF" },
{ "name": "button_text", "value": "#FFFFFF" },
{ "name": "success", "value": "#00B34A" },
{ "name": "success_bg", "value": "#0A00B34A" },
{ "name": "error", "value": "#E02020" },
{ "name": "error_bg", "value": "#0AE02020" },
{ "name": "shadow_color", "value": "#1A000000" },
{ "name": "grid_line", "value": "#14000000" },
{ "name": "today_bg", "value": "#14007DFF" },
{ "name": "selected_bg", "value": "#007DFF" },
{ "name": "chip_off_bg", "value": "#0D000000" }
]
}
@@ -0,0 +1,8 @@
{
"float": [
{
"name": "page_text_font_size",
"value": "50fp"
}
]
}
@@ -0,0 +1,52 @@
{
"string": [
{
"name": "module_desc",
"value": "同步日历:以同步为核心的日历应用"
},
{
"name": "EntryAbility_desc",
"value": "同步日历主界面"
},
{
"name": "EntryAbility_label",
"value": "同步日历"
},
{
"name": "perm_read_calendar",
"value": "读取系统日历日程,用于在日历视图中混合展示"
},
{
"name": "perm_write_calendar",
"value": "写入系统日历,用于保存您创建的日程"
},
{
"name": "perm_read_whole_calendar",
"value": "读取所有日历账户的日程,用于在日历中统一展示"
},
{
"name": "card_ability_desc",
"value": "同步日历服务卡片"
},
{
"name": "card_desc",
"value": "展示从今天开始的日程安排"
},
{
"name": "card_2x2_name",
"value": "下一条日程"
},
{
"name": "card_2x4_name",
"value": "日程速览"
},
{
"name": "card_4x4_name",
"value": "日程列表"
},
{
"name": "card_6x4_name",
"value": "日程大全"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

@@ -0,0 +1,7 @@
{
"layered-image":
{
"background" : "$media:background",
"foreground" : "$media:foreground"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

@@ -0,0 +1,3 @@
{
"allowToBackupRestore": true
}
@@ -0,0 +1,76 @@
{
"forms": [
{
"name": "WidgetCard2x2",
"displayName": "$string:card_2x2_name",
"description": "$string:card_desc",
"src": "./ets/pages/widget/Widget2x2.ets",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "light",
"uiSyntax": "arkts",
"isDefault": true,
"updateEnabled": true,
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "2*2",
"supportDimensions": ["2*2"]
},
{
"name": "WidgetCard2x4",
"displayName": "$string:card_2x4_name",
"description": "$string:card_desc",
"src": "./ets/pages/widget/Widget4x2.ets",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "light",
"uiSyntax": "arkts",
"isDefault": false,
"updateEnabled": true,
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "2*4",
"supportDimensions": ["2*4"]
},
{
"name": "WidgetCard4x4",
"displayName": "$string:card_4x4_name",
"description": "$string:card_desc",
"src": "./ets/pages/widget/Widget4x4.ets",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "light",
"uiSyntax": "arkts",
"isDefault": false,
"updateEnabled": true,
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "4*4",
"supportDimensions": ["4*4"]
},
{
"name": "WidgetCard6x4",
"displayName": "$string:card_6x4_name",
"description": "$string:card_desc",
"src": "./ets/pages/widget/Widget6x4.ets",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "light",
"uiSyntax": "arkts",
"isDefault": false,
"updateEnabled": true,
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "6*4",
"supportDimensions": ["6*4"]
}
]
}
@@ -0,0 +1,11 @@
{
"src": [
"pages/Index",
"pages/AccountsPage",
"pages/AddAccountPage",
"pages/CalendarListPage",
"pages/EditAccountPage",
"pages/EventEditPage",
"pages/SettingsPage"
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"modelVersion": "26.0.0",
"dependencies": {
},
"execution": {
// "analyze": "normal",
// "daemon": true,
// "incremental": true,
// "parallel": true
},
"logging": {
},
"debugging": {
},
"nodeOptions": {
}
}
+6
View File
@@ -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. */
}
+28
View File
@@ -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"
}
}
}
+10
View File
@@ -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"
}
}