From 04fdf3d386dcb724ec4f2ca7d9239a5522c019d3 Mon Sep 17 00:00:00 2001 From: Yang Yongquan Date: Sun, 13 Sep 2026 20:25:18 +0800 Subject: [PATCH] =?UTF-8?q?1.=E5=A2=9E=E5=8A=A0=E4=BA=86=E9=87=8D=E5=8A=9B?= =?UTF-8?q?=E6=84=9F=E5=BA=94=EF=BC=8C=E4=B9=9F=E5=B0=B1=E6=98=AF=E6=A8=AA?= =?UTF-8?q?=E5=B1=8F=E5=93=8D=E5=BA=94=E5=BC=8F=E5=B8=83=E5=B1=80=E3=80=82?= =?UTF-8?q?=202.=E4=BF=AE=E6=94=B9=E4=BA=86=E6=B2=89=E6=B5=B8=E5=BC=8F?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E3=80=82=203.=E4=BF=AE=E6=94=B9=E4=BA=86?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=97=A5=E7=A8=8B=E5=92=8C=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E6=97=A5=E7=A8=8B=E9=A1=B5=E9=9D=A2=EF=BC=8C=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E4=BA=86=E9=87=8D=E5=A4=8D=E3=80=81=E6=8F=90=E9=86=92=E7=AD=89?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E3=80=82=204.=E4=BF=AE=E6=94=B9=E4=BA=86?= =?UTF-8?q?=E6=9D=83=E9=99=90=E9=97=AE=E9=A2=98=EF=BC=8C=E5=A6=82=E6=9E=9C?= =?UTF-8?q?=E6=97=A5=E5=8E=86=E6=9C=AC=E6=98=AF=E5=8F=AA=E8=AF=BB=EF=BC=8C?= =?UTF-8?q?=E5=88=99=E6=9C=89=E5=88=A0=E9=99=A4=E7=BA=BF=E5=81=9A=E6=A0=87?= =?UTF-8?q?=E8=AF=86=E3=80=82=E5=90=8C=E6=97=B6=EF=BC=8C=E5=AF=B9=E4=BA=8E?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=E7=9A=84=E6=97=A5=E7=A8=8B=EF=BC=8C=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E5=90=8E=E5=B0=86=E4=B8=8D=E5=86=8D=E8=BF=9B=E5=85=A5?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E9=A1=B5=E9=9D=A2=EF=BC=8C=E8=80=8C=E6=98=AF?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E8=AF=A6=E6=83=85=E3=80=82=205.=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=97=A5=E5=8E=86=E7=9A=84=E5=A4=84=E7=90=86=E3=80=82?= =?UTF-8?q?=E7=BB=99=E7=94=A8=E6=88=B7=E4=B8=A4=E4=B8=AA=E9=80=89=E6=8B=A9?= =?UTF-8?q?=EF=BC=8C=E7=AC=AC=E4=B8=80=E4=B8=AA=E9=80=89=E6=8B=A9=E5=B0=B1?= =?UTF-8?q?=E6=98=AF=E5=8F=AA=E6=98=BE=E7=A4=BA=E7=B3=BB=E7=BB=9F=E6=97=A5?= =?UTF-8?q?=E5=8E=86=E3=80=82=E7=AC=AC=E4=BA=8C=E7=A7=8D=E9=80=89=E6=8B=A9?= =?UTF-8?q?=EF=BC=8C=E7=94=A8=E6=88=B7=E5=8F=AF=E4=BB=A5=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E4=B8=80=E4=B8=AAcaldav=E8=B4=A6=E6=88=B7=E4=B8=AD=E7=9A=84?= =?UTF-8?q?=E6=9F=90=E4=B8=80=E4=B8=AA=E6=97=A5=E5=8E=86=E6=9C=AC=EF=BC=8C?= =?UTF-8?q?=E6=8A=8A=E7=B3=BB=E7=BB=9F=E6=97=A5=E5=8E=86=E4=B8=AD=E7=9A=84?= =?UTF-8?q?=E6=97=A5=E7=A8=8B=EF=BC=8C=E5=8C=85=E6=8B=AC=E6=97=A5=E5=8E=86?= =?UTF-8?q?=E6=97=A5=E7=A8=8B=E5=92=8C=E5=BA=94=E7=94=A8=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E7=9A=84=E6=97=A5=E7=A8=8B=EF=BC=8C=E9=83=BD=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E5=87=BA=E6=9D=A5=EF=BC=8C=E7=84=B6=E5=90=8E=E5=8A=A0=E5=85=A5?= =?UTF-8?q?=E5=88=B0=E8=BF=99=E4=B8=AA=E6=97=A5=E5=8E=86=E6=9C=AC=E4=B8=8B?= =?UTF-8?q?=EF=BC=8C=E6=9C=80=E5=90=8E=E5=90=8C=E6=AD=A5=E5=88=B0caldav?= =?UTF-8?q?=E7=9A=84=E6=9C=8D=E5=8A=A1=E5=99=A8=E4=B8=8A=EF=BC=8C=E8=BF=99?= =?UTF-8?q?=E6=A0=B7=E7=9A=84=E5=A5=BD=E5=A4=84=E6=98=AF=EF=BC=8C=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E4=B8=A2=E5=A4=B1=E4=BA=86=EF=BC=8C=E6=88=96=E8=80=85?= =?UTF-8?q?=E6=8D=A2=E4=BA=86=E6=89=8B=E6=9C=BA=E5=93=81=E7=89=8C=E5=9E=8B?= =?UTF-8?q?=E5=8F=B7=EF=BC=8C=E6=89=8B=E6=9C=BA=E4=B8=8A=E7=9A=84=E6=97=A5?= =?UTF-8?q?=E7=A8=8B=E4=BB=8D=E7=84=B6=E5=9C=A8=E8=87=AA=E5=B7=B1=E7=9A=84?= =?UTF-8?q?caldav=E6=9C=8D=E5=8A=A1=E5=99=A8=E4=B8=8A=E6=9C=89=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E5=A4=87=E4=BB=BD=E3=80=82=E5=BD=93=E7=84=B6=EF=BC=8C?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=97=A5=E5=8E=86=E4=B8=AD=E7=9A=84caldav?= =?UTF-8?q?=E6=97=A5=E5=8E=86=EF=BC=8C=E5=B0=B1=E4=B8=8D=E4=BC=9A=E5=86=8D?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E4=BA=86=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- entry/src/main/ets/common/AccountStore.ets | 9 +- entry/src/main/ets/common/AppSettings.ets | 77 ++++ .../main/ets/common/CalendarDataService.ets | 11 + entry/src/main/ets/common/DavClient.ets | 67 ++- entry/src/main/ets/common/EventDb.ets | 11 + entry/src/main/ets/common/IcsUtil.ets | 19 + entry/src/main/ets/common/SyncEngine.ets | 75 +++- .../main/ets/common/SystemCalendarImport.ets | 121 ++++++ .../main/ets/entryability/EntryAbility.ets | 22 + entry/src/main/ets/pages/EventEditPage.ets | 331 ++++++++++---- entry/src/main/ets/pages/Index.ets | 410 +++++++++++++----- entry/src/main/ets/pages/SettingsPage.ets | 257 ++++++++++- .../src/main/ets/syncwork/SyncWorkAbility.ets | 7 + entry/src/main/module.json5 | 4 +- 14 files changed, 1211 insertions(+), 210 deletions(-) create mode 100644 entry/src/main/ets/common/SystemCalendarImport.ets diff --git a/entry/src/main/ets/common/AccountStore.ets b/entry/src/main/ets/common/AccountStore.ets index 6401a80..80c46a8 100644 --- a/entry/src/main/ets/common/AccountStore.ets +++ b/entry/src/main/ets/common/AccountStore.ets @@ -26,6 +26,8 @@ export class DavAccount { calendarNames: string[] = []; /** 服务器端定义的日历本颜色(calendar-color),与 calendarHrefs 一一对应,空串表示未定义 */ calendarColors: string[] = []; + /** 各日历本写权限('1'=可写 '0'=只读),与 calendarHrefs 一一对应;空数组表示未知(按可写处理) */ + calendarWritable: string[] = []; itemCount: number = 0; lastSyncTime: string = ''; } @@ -37,6 +39,7 @@ export class CalSource { color: string = '#007DFF'; source: string = 'dav'; // 'dav' | 'system' | 'local' visible: boolean = true; + writable: boolean = true; // 只读日历本中的日程只能查看详情,不能编辑 } /** 与 UI 无关的调色板 */ @@ -81,7 +84,8 @@ export class AccountStore { acc.calendarHrefs.join(';'), acc.calendarNames.join(';'), acc.calendarColors.join(';'), - safe(acc.id) + safe(acc.id), + acc.calendarWritable.join(';') ]; return parts.join('|'); } @@ -109,6 +113,9 @@ export class AccountStore { if (parts.length >= 11) { acc.id = parts[10]; } + if (parts.length >= 12) { + acc.calendarWritable = parts[11] === '' ? [] : parts[11].split(';'); + } return acc; } diff --git a/entry/src/main/ets/common/AppSettings.ets b/entry/src/main/ets/common/AppSettings.ets index 9d7dd95..567b76e 100644 --- a/entry/src/main/ets/common/AppSettings.ets +++ b/entry/src/main/ets/common/AppSettings.ets @@ -9,6 +9,83 @@ export class AppSettings { private static readonly KEY_SHOW_SYSTEM: string = 'show_system_calendar'; private static readonly KEY_SYNC_INTERVAL: string = 'sync_interval_minutes'; private static readonly KEY_BACKGROUND_SYNC: string = 'background_sync'; + private static readonly KEY_SYS_MODE: string = 'sys_cal_mode'; // 'display' | 'backup' + private static readonly KEY_BACKUP_KEY: string = 'sys_backup_cal_key'; // 备份目标 DAV 日历本 + private static readonly KEY_MANUAL_READONLY: string = 'manual_readonly_keys'; // 手动标记只读的 calKey + + /** + * 手动标记为只读的日历本 calKey 列表。 + * 部分服务器(如群晖某些共享方式)不在 CalDAV 层拒绝写入,自动探测无法区分, + * 由用户手动标记,标记后其中日程只显示详情、不出现在新建日程选择中。 + */ + static async getManualReadonlyKeys(context: common.Context): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + const raw: string = await store.get(AppSettings.KEY_MANUAL_READONLY, '') as string; + return raw === '' ? [] : raw.split(';'); + } catch (err) { + return []; + } + } + + static async setManualReadonlyKeys(context: common.Context, keys: string[]): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + await store.put(AppSettings.KEY_MANUAL_READONLY, keys.join(';')); + await store.flush(); + } catch (err) { + const e = err as BusinessError; + console.error(`保存手动只读标记失败: ${e.message}`); + } + } + + /** 系统日历模式:display = 仅混合显示;backup = 本地系统日程备份到选定 CalDAV 日历本 */ + static async getSysCalMode(context: common.Context): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + return await store.get(AppSettings.KEY_SYS_MODE, 'display') as string; + } catch (err) { + return 'display'; + } + } + + static async setSysCalMode(context: common.Context, mode: string): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + await store.put(AppSettings.KEY_SYS_MODE, mode); + await store.flush(); + } catch (err) { + const e = err as BusinessError; + console.error(`保存系统日历模式失败: ${e.message}`); + } + } + + /** 备份目标日历本 calKey(accId_序号),空 = 未选择 */ + static async getBackupCalKey(context: common.Context): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + return await store.get(AppSettings.KEY_BACKUP_KEY, '') as string; + } catch (err) { + return ''; + } + } + + static async setBackupCalKey(context: common.Context, calKey: string): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + await store.put(AppSettings.KEY_BACKUP_KEY, calKey); + await store.flush(); + } catch (err) { + const e = err as BusinessError; + console.error(`保存备份目标失败: ${e.message}`); + } + } /** 是否开启后台持续同步(长时任务,默认关) */ static async getBackgroundSync(context: common.Context): Promise { diff --git a/entry/src/main/ets/common/CalendarDataService.ets b/entry/src/main/ets/common/CalendarDataService.ets index e693588..96eea8c 100644 --- a/entry/src/main/ets/common/CalendarDataService.ets +++ b/entry/src/main/ets/common/CalendarDataService.ets @@ -25,6 +25,7 @@ export class DisplayEvent { color: string = '#007DFF'; completed: boolean = false; // 仅待办使用 recurring: boolean = false; // 是否为重复日程展开出的发生 + writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑) } export class CalendarDataService { @@ -53,6 +54,7 @@ export class CalendarDataService { /** 收集全部日历来源:DAV 日历本 + 本机 + 系统日历账户 */ static async loadSources(context: common.Context): Promise { const sources: CalSource[] = []; + const manualKeys: string[] = await AppSettings.getManualReadonlyKeys(context); try { const accounts: DavAccount[] = await AccountStore.loadAll(context); for (const acc of accounts) { @@ -69,6 +71,10 @@ export class CalendarDataService { s.color = color !== '' ? color : BookPalette.colorFor(i); s.source = 'dav'; s.visible = true; + // 写权限:服务器探测结果 + 手动标记(部分服务器不在 CalDAV 层拒绝写入) + const detected: boolean = acc.calendarWritable.length > i + ? acc.calendarWritable[i] !== '0' : true; + s.writable = detected && !manualKeys.includes(s.calKey); sources.push(s); } } @@ -142,6 +148,10 @@ export class CalendarDataService { const found = sources.find((s: CalSource): boolean => s.calKey === key); return found !== undefined ? found.name : ''; }; + const writableOf = (key: string): boolean => { + const found = sources.find((s: CalSource): boolean => s.calKey === key); + return found !== undefined ? found.writable : true; + }; // 1) 本地库(DAV + 本机);重复日程(RRULE)按规则展开为窗口内的多次发生 try { @@ -202,6 +212,7 @@ export class CalendarDataService { d.calName = baseName; d.color = baseColor; d.recurring = e.rrule !== ''; + d.writable = writableOf(e.calKey); result.push(d); occTotal++; } diff --git a/entry/src/main/ets/common/DavClient.ets b/entry/src/main/ets/common/DavClient.ets index 3d2f293..1ceb878 100644 --- a/entry/src/main/ets/common/DavClient.ets +++ b/entry/src/main/ets/common/DavClient.ets @@ -12,10 +12,12 @@ export class RemoteItem { ics: string = ''; // VCALENDAR 文本 } -/** PROPFIND 返回的集合颜色 */ +/** PROPFIND 返回的集合颜色与写权限 */ export class DavColorEntry { href: string = ''; // 集合路径(服务器返回的是路径,不带域名) color: string = ''; // 规范化后的 #RRGGBB,可能为空 + writable: boolean = true; // current-user-privilege 是否含 write 权限(默认可写) + privilegeKnown: boolean = false; // 服务器是否返回了 current-user-privilege-set(未返回时需要写探测) } export class DavClient { @@ -28,6 +30,7 @@ export class DavClient { '' + '' + + '' + ''; const httpRequest = http.createHttp(); try { @@ -71,6 +74,13 @@ export class DavClient { color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color')); } entry.color = color; + // 写权限:current-user-privilege-set 存在且不含任何 write 权限 → 只读 + const privSet: string = DavClient.extractTag(block, 'current-user-privilege-set'); + if (privSet !== '') { + entry.privilegeKnown = true; + entry.writable = /write/i.test(privSet); + LogUtil.write(`PROPFIND ${href} 权限声明:${privSet.replace(/\s+/g, '').substring(0, 150)}`); + } result.push(entry); } if (result.length > 0 && result.every((e: DavColorEntry): boolean => e.color === '')) { @@ -100,6 +110,61 @@ export class DavClient { return 'Basic ' + buffer.from(`${username}:${password}`).toString('base64'); } + /** + * 写权限探测:向日历本 PUT 一个探测资源—— + * 2xx → 可写(随后删除探测资源);403/401 等 → 只读;网络异常 → 乐观按可写。 + * 用于服务器不返回 current-user-privilege-set 的情况(如部分 Synology 配置)。 + */ + static async probeWritable(href: string, auth: string): Promise { + const url: string = href.endsWith('/') + ? `${href}synccalendar-probe.ics` : `${href}/synccalendar-probe.ics`; + const ics: string = 'BEGIN:VCALENDAR\r\n' + + 'VERSION:2.0\r\n' + + 'PRODID:-//SyncCalendar//Probe//CN\r\n' + + 'BEGIN:VEVENT\r\n' + + 'UID:syncprobe\r\n' + + 'DTSTAMP:20000101T000000Z\r\n' + + 'DTSTART:20000101T000000Z\r\n' + + 'DTEND:20000101T010000Z\r\n' + + 'SUMMARY:probe\r\n' + + 'END:VEVENT\r\n' + + 'END:VCALENDAR\r\n'; + 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: 15000 + }); + LogUtil.write(`HTTP PUT(探测) ${url} → ${resp.responseCode}`); + if (resp.responseCode >= 200 && resp.responseCode < 300) { + // 写入成功:删除探测资源,避免污染日历本 + try { + await DavClient.deleteRemote(url, auth); + } catch (err) { + // 删除失败不影响权限判定 + } + return true; + } + if (resp.responseCode === 412 || resp.responseCode === 405 || resp.responseCode === 409) { + // 资源已存在/方法冲突等:说明有写权限 + return true; + } + return false; // 403/401 等 → 只读 + } catch (err) { + // 网络异常无法判定:乐观按可写,避免误标只读 + return true; + } finally { + httpRequest.destroy(); + } + } + /** REPORT calendar-query:全量拉取某日历本内所有 VEVENT(不加时间范围,保证数据完整) */ static async reportCalendar(href: string, auth: string): Promise { return DavClient.reportComponents(href, auth, 'VEVENT', ''); diff --git a/entry/src/main/ets/common/EventDb.ets b/entry/src/main/ets/common/EventDb.ets index 1c8fed4..e08aea1 100644 --- a/entry/src/main/ets/common/EventDb.ets +++ b/entry/src/main/ets/common/EventDb.ets @@ -409,6 +409,17 @@ export class EventDb { return `新增${added} 更新${updated} 删除${removed} 不变${unchanged}`; } + /** 按 UID 判断事件是否已存在(系统日历备份导入的幂等判重) */ + static async uidExists(context: common.Context, uid: string): Promise { + const store = await EventDb.getDb(context); + const predicates = new relationalStore.RdbPredicates('events'); + predicates.equalTo('uid', uid); + const rs = await store.query(predicates); + const exists: boolean = rs.rowCount > 0; + rs.close(); + return exists; + } + /** 删除某账号全部本地事件(删除账号时调用) */ static async deleteAccountEvents(context: common.Context, accId: string): Promise { const store = await EventDb.getDb(context); diff --git a/entry/src/main/ets/common/IcsUtil.ets b/entry/src/main/ets/common/IcsUtil.ets index 5a91028..3a8b09b 100644 --- a/entry/src/main/ets/common/IcsUtil.ets +++ b/entry/src/main/ets/common/IcsUtil.ets @@ -240,13 +240,32 @@ export class IcsUtil { `DTSTAMP:${now}\r\n` + `${dtstart}\r\n` + `${dtend}\r\n` + + (e.rrule !== '' ? `RRULE:${e.rrule}\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` : '') + + (e.reminder > 0 ? IcsUtil.valarm(e.reminder) : '') + 'END:VEVENT\r\n' + 'END:VCALENDAR\r\n'; } + /** 由提前分钟数生成 VALARM(TRIGGER 负时长 = 提前触发) */ + private static valarm(minutes: number): string { + let trigger: string; + if (minutes >= 1440 && minutes % 1440 === 0) { + trigger = `-P${minutes / 1440}D`; + } else if (minutes >= 60 && minutes % 60 === 0) { + trigger = `-PT${minutes / 60}H`; + } else { + trigger = `-PT${minutes}M`; + } + return 'BEGIN:VALARM\r\n' + + `TRIGGER:${trigger}\r\n` + + 'ACTION:DISPLAY\r\n' + + 'DESCRIPTION:提醒\r\n' + + 'END:VALARM\r\n'; + } + static escape(s: string): string { return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n'); } diff --git a/entry/src/main/ets/common/SyncEngine.ets b/entry/src/main/ets/common/SyncEngine.ets index 72d3798..698fc63 100644 --- a/entry/src/main/ets/common/SyncEngine.ets +++ b/entry/src/main/ets/common/SyncEngine.ets @@ -70,6 +70,9 @@ export class SyncEngine { } // 一个资源可能包含主事件 + 单次覆盖实例(RECURRENCE-ID),全部入库 for (const r of parsed) { + if (r.uid === 'syncprobe') { + continue; // 写权限探测资源(万一删除失败),不入库展示 + } if (r.uid === '') { r.uid = SyncEngine.uidFromHref(it.href); } @@ -113,31 +116,44 @@ export class SyncEngine { * 失败静默(颜色不影响数据正确性) */ static async refreshCalendarColors(acc: DavAccount, auth: string): Promise { + let entries: DavColorEntry[] = []; 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; - } - } + entries = await DavClient.propfindColors(acc.serverUrl, auth); } catch (err) { const e = err as BusinessError; - console.info(`刷新日历本颜色失败(忽略): ${e.message}`); + LogUtil.write(`PROPFIND 颜色/权限失败(继续探测写权限): ${e.message}`); + } + 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; + } + // 回写各日历本写权限('1'=可写 '0'=只读) + while (acc.calendarWritable.length <= i) { + acc.calendarWritable.push('1'); + } + const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`; + if (found !== undefined && found.privilegeKnown) { + // 服务器声明了权限:直接采用 + acc.calendarWritable[i] = found.writable ? '1' : '0'; + LogUtil.write(`日历本[${i}]「${calName}」写权限(privilege):${found.writable ? '可写' : '只读'}`); + } else { + // 服务器未声明权限(如部分 Synology 配置)或 PROPFIND 未匹配到该本:真实写探测 + const w: boolean = await DavClient.probeWritable(target, auth); + acc.calendarWritable[i] = w ? '1' : '0'; + LogUtil.write(`日历本[${i}]「${calName}」写权限(探测):${w ? '可写' : '只读'}`); + } } } @@ -159,17 +175,28 @@ export class SyncEngine { 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) { + // 只读日历本:推送必然 403,跳过并保留 dirty(权限恢复后可再推) + const bookIdx: number = acc.calendarHrefs.indexOf(e.href); + if (bookIdx >= 0 && acc.calendarWritable.length > bookIdx + && acc.calendarWritable[bookIdx] === '0') { + LogUtil.write(`推送跳过只读日历本事件「${e.title}」(uid=${e.uid})`); + continue; + } if (e.kind === 'todo') { // 待办只读:本地不会有 dirty 待办,兜底清除 await EventDb.clearDirty(context, e.id, e.etag); continue; } - if (e.recurring) { - // 重复日程实例推送会破坏服务器整个序列,暂不支持 + if (e.recurring && e.rrule === '') { + // 重复日程的"单次覆盖实例"(RECURRENCE-ID)推送会破坏服务器整个序列,暂不支持 await EventDb.clearDirty(context, e.id, e.etag); LogUtil.write(`推送跳过重复日程实例「${e.title}」(uid=${e.uid})`); continue; } + if (e.recurring) { + // 重复主事件(含 RRULE,含本机新建的重复日程):整条 PUT 覆盖推送 + LogUtil.write(`推送重复主事件「${e.title}」(uid=${e.uid})`); + } const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`; if (e.deleted) { await DavClient.deleteRemote(url, auth); diff --git a/entry/src/main/ets/common/SystemCalendarImport.ets b/entry/src/main/ets/common/SystemCalendarImport.ets new file mode 100644 index 0000000..2ac612c --- /dev/null +++ b/entry/src/main/ets/common/SystemCalendarImport.ets @@ -0,0 +1,121 @@ +// entry/src/main/ets/common/SystemCalendarImport.ets +// 系统日历备份:把手机"本地"系统日历(非 CalDAV 同步日历)的全部日程导入 +// 用户选定的 CalDAV 日历本(置 dirty 待推送),随后随同步上传服务器。 +// 价值:手机丢失/换机后,系统日历日程在用户自己的 CalDAV 服务器上仍有备份。 +import { common } from '@kit.AbilityKit'; +import { calendarManager } from '@kit.CalendarKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { AccountStore, DavAccount } from './AccountStore'; +import { EventDb, LocalEvent } from './EventDb'; +import { AppSettings } from './AppSettings'; +import { LogUtil } from './LogUtil'; + +export class SystemCalendarImport { + /** + * 备份模式下的导入入口(display 模式直接返回 0)。 + * 幂等:uid = syscal-<系统日历id>-<事件id>,已存在则跳过,因此每次同步都可安全执行。 + * 只读取 calendarType = LOCAL 的系统日历(手机本地日历/应用创建的日程), + * 系统 CalDAV 同步产生的账户日历一律跳过,避免死循环回灌。 + */ + static async importIfNeeded(context: common.Context): Promise { + try { + const mode: string = await AppSettings.getSysCalMode(context); + if (mode !== 'backup') { + return 0; + } + const calKey: string = await AppSettings.getBackupCalKey(context); + if (calKey === '') { + LogUtil.write('系统日历备份:未选择目标日历本,跳过导入'); + return 0; + } + // 定位目标 CalDAV 日历本 + const accounts: DavAccount[] = await AccountStore.loadAll(context); + let targetHref: string = ''; + for (const acc of accounts) { + for (let i = 0; i < acc.calendarHrefs.length; i++) { + if (`${acc.id}_${i}` === calKey) { + targetHref = acc.calendarHrefs[i]; + } + } + } + if (targetHref === '') { + LogUtil.write(`系统日历备份:目标日历本 ${calKey} 不存在(账号可能已删除),跳过导入`); + return 0; + } + const uiContext = context as common.UIAbilityContext; + const mgr: calendarManager.CalendarManager = calendarManager.getCalendarManager(uiContext); + const calendars: calendarManager.Calendar[] = await mgr.getAllCalendars(); + let imported: number = 0; + let skippedCal: number = 0; + for (const cal of calendars) { + try { + // 只处理"本地"类型系统日历;CalDAV/订阅等账户日历的数据源本来就在服务器上,跳过防回灌 + const account: calendarManager.CalendarAccount = cal.getAccount(); + if (account.type !== calendarManager.CalendarType.LOCAL) { + skippedCal++; + continue; + } + const events: calendarManager.Event[] = await cal.getEvents(); + for (const ev of events) { + if (ev.id === undefined) { + continue; + } + const uid: string = `syscal-${account.name}-${ev.id}`; + if (await EventDb.uidExists(context, uid)) { + continue; + } + const e = new LocalEvent(); + e.uid = uid; + e.calKey = calKey; + e.href = targetHref; + e.remotePath = encodeURIComponent(uid) + '.ics'; + e.title = ev.title !== undefined ? ev.title : ''; + e.description = ev.description !== undefined ? ev.description : ''; + e.location = ev.location !== undefined && ev.location.location !== undefined + ? ev.location.location : ''; + e.startTime = ev.startTime; + e.endTime = ev.endTime; + e.isAllDay = ev.isAllDay === true; + e.kind = 'event'; + e.rrule = SystemCalendarImport.rruleOf(ev); + e.recurring = e.rrule !== ''; + await EventDb.insertLocal(context, e); // insertLocal 置 dirty,随同步推送 + imported++; + } + } catch (err) { + // 单个日历读取失败不影响其余 + } + } + if (imported > 0 || skippedCal > 0) { + LogUtil.write(`系统日历备份:新增 ${imported} 条 → ${calKey}(跳过账户日历 ${skippedCal} 个)`); + } + return imported; + } catch (err) { + const e = err as BusinessError; + LogUtil.write(`系统日历备份导入失败(忽略): ${e.message}`); + return 0; + } + } + + /** 系统日程的 recurrenceRule → 尽力转换的 RRULE 字符串(仅 FREQ/INTERVAL,复杂规则服务器端可再完善) */ + private static rruleOf(ev: calendarManager.Event): string { + try { + const rr = ev.recurrenceRule; + if (rr === undefined) { + return ''; + } + const freqMap: string[] = ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY']; + const fi: number = rr.recurrenceFrequency as number; + if (Number.isNaN(fi) || fi < 0 || fi >= freqMap.length) { + return ''; + } + let rrule: string = `FREQ=${freqMap[fi]}`; + if (rr.interval !== undefined && rr.interval > 1) { + rrule += `;INTERVAL=${rr.interval}`; + } + return rrule; + } catch (err) { + return ''; + } + } +} diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 8a0a161..f974a4c 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,6 +1,7 @@ import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { window } from '@kit.ArkUI'; +import { BusinessError } from '@kit.BasicServicesKit'; import { BackgroundSyncService } from '../common/BackgroundSyncService'; const DOMAIN = 0x0000; @@ -24,6 +25,27 @@ export default class EntryAbility extends UIAbility { return; } hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.'); + // 沉浸式配色:状态栏/导航条与页面背景同色(app.color.page_bg = #F1F3F5), + // 消除顶部通知栏和底部的白色条 + windowStage.getMainWindow((werr: BusinessError, win: window.Window) => { + if (werr.code) { + hilog.error(DOMAIN, 'testTag', 'Failed to get main window. Cause: %{public}s', JSON.stringify(werr)); + return; + } + try { + win.setWindowSystemBarProperties({ + statusBarColor: '#F1F3F5', + statusBarContentColor: '#182431', + navigationBarColor: '#F1F3F5', + navigationBarContentColor: '#182431' + }); + // 鸿蒙 NEXT 导航条背景跟随窗口背景色:设为页面背景灰,消除底部白条 + win.setWindowBackgroundColor('#F1F3F5'); + } catch (barErr) { + hilog.error(DOMAIN, 'testTag', 'Failed to set system bar properties. Cause: %{public}s', + JSON.stringify(barErr)); + } + }); }); try { this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); diff --git a/entry/src/main/ets/pages/EventEditPage.ets b/entry/src/main/ets/pages/EventEditPage.ets index cc6d439..5c01a67 100644 --- a/entry/src/main/ets/pages/EventEditPage.ets +++ b/entry/src/main/ets/pages/EventEditPage.ets @@ -5,6 +5,7 @@ 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 { AppSettings } from '../common/AppSettings'; import { DavClient } from '../common/DavClient'; import { SyncEngine } from '../common/SyncEngine'; @@ -30,6 +31,13 @@ struct EventEditPage { @State isSaving: boolean = false; @State statusMsg: string = ''; @State isExisting: boolean = false; + @State repeatMode: string = 'none'; // none|daily|workday|weekly|monthly|yearly|custom + @State reminderMin: number = 0; // 提前提醒分钟数,0 = 不提醒 + @State pickerShow: boolean = false; // 开始/结束时间选择底部弹层(日期+时间一次选完) + @State pickerIsEnd: boolean = false; + @State pickDate: Date = new Date(); + @State pickHour: number = 9; + @State pickMin: number = 0; private event: LocalEvent | null = null; aboutToAppear(): Promise { @@ -41,7 +49,7 @@ struct EventEditPage { if (context === undefined) { return; } - // 收集可写入的日历本(DAV + 本机) + // 收集可写入的日历本(DAV 可写日历本 + 本机);只读日历本不出现在新建/编辑选择中 const sources: CalSourceWithHref[] = await CalendarDataBridge.loadWritableSources(context); const choices: BookChoice[] = []; for (const s of sources) { @@ -68,6 +76,8 @@ struct EventEditPage { this.startMs = loaded.startTime; this.endMs = loaded.endTime; this.chosenKey = loaded.calKey; + this.repeatMode = loaded.rrule !== '' ? this.modeFromRrule(loaded.rrule) : 'none'; + this.reminderMin = loaded.reminder; return; } } @@ -98,62 +108,81 @@ struct EventEditPage { 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; - } - }); + /** 重复模式 → RRULE 字符串(RruleUtil 已支持这些规则) */ + private rruleForMode(mode: string): string { + if (mode === 'daily') { + return 'FREQ=DAILY'; + } + if (mode === 'workday') { + return 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR'; + } + if (mode === 'weekly') { + return 'FREQ=WEEKLY'; + } + if (mode === 'monthly') { + return 'FREQ=MONTHLY'; + } + if (mode === 'yearly') { + return 'FREQ=YEARLY'; + } + return ''; } - 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; - } - }); + /** RRULE → 重复模式(无法识别的规则归为 custom,保存时保留原规则) */ + private modeFromRrule(rrule: string): string { + const u: string = rrule.toUpperCase(); + if (u.includes('FREQ=DAILY')) { + return 'daily'; + } + if (u.includes('FREQ=WEEKLY')) { + const hasWeekday: boolean = u.includes('MO') && u.includes('TU') && u.includes('WE') + && u.includes('TH') && u.includes('FR'); + return hasWeekday ? 'workday' : 'weekly'; + } + if (u.includes('FREQ=MONTHLY')) { + return 'monthly'; + } + if (u.includes('FREQ=YEARLY')) { + return 'yearly'; + } + return 'custom'; } - 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 reminderLabel(m: number): string { + if (m === 0) { + return '不提醒'; + } + if (m < 60) { + return `提前${m}分钟`; + } + if (m < 1440) { + return `提前${m / 60}小时`; + } + return `提前${m / 1440}天`; } - 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 openPicker(isEnd: boolean): void { + this.pickerIsEnd = isEnd; + const base = new Date(isEnd ? this.endMs : this.startMs); + this.pickDate = new Date(base.getFullYear(), base.getMonth(), base.getDate()); + this.pickHour = base.getHours(); + this.pickMin = base.getMinutes(); + this.pickerShow = true; + } + + private applyPicker(): void { + const d = this.pickDate; + const picked: number = new Date(d.getFullYear(), d.getMonth(), d.getDate(), + this.pickHour, this.pickMin).getTime(); + if (this.pickerIsEnd) { + // 全天日程的结束存为"当天 23:59:59.999"(排他日期前 1 毫秒) + this.endMs = this.allDay ? picked + 86399999 : picked; + } else { + const dur: number = Math.max(0, this.endMs - this.startMs); + this.startMs = picked; + this.endMs = this.allDay ? picked + 86399999 : picked + dur; + } } private toggleAllDay(): void { @@ -186,8 +215,9 @@ struct EventEditPage { if (this.isSaving || !this.validate()) { return; } - if (this.event !== null && this.event.recurring) { - this.statusMsg = '重复日程(系列中的一天)暂不支持修改,请到服务器端调整重复规则'; + // 仅阻止"单次覆盖实例"(RECURRENCE-ID)的修改;重复主事件(含 RRULE)允许编辑 + if (this.event !== null && this.event.recurring && this.event.rrule === '') { + this.statusMsg = '重复日程的单次修改暂不支持,请到服务器端调整重复规则'; return; } this.isSaving = true; @@ -208,6 +238,15 @@ struct EventEditPage { e.isAllDay = this.allDay; e.calKey = book !== null ? book.calKey : 'local'; e.href = book !== null ? book.href : ''; + // 重复规则与提醒 + if (this.repeatMode === 'custom') { + // 无法识别的既有规则原样保留 + e.rrule = this.event !== null ? this.event.rrule : ''; + } else { + e.rrule = this.rruleForMode(this.repeatMode); + } + e.recurring = e.rrule !== ''; + e.reminder = this.reminderMin; if (isNew) { e.uid = `syncal-${Date.now()}-${Math.floor(Math.random() * 1000000)}`; e.remotePath = encodeURIComponent(e.uid) + '.ics'; @@ -242,8 +281,8 @@ struct EventEditPage { if (this.event === null || this.isSaving) { return; } - if (this.event.recurring) { - this.statusMsg = '重复日程(系列中的一天)暂不支持删除,请到服务器端调整重复规则'; + if (this.event !== null && this.event.recurring && this.event.rrule === '') { + this.statusMsg = '重复日程的单次删除暂不支持,请到服务器端调整重复规则'; return; } this.isSaving = true; @@ -274,11 +313,12 @@ struct EventEditPage { build() { Column({ space: 14 }) { - // 顶部 + // 顶部(与内容区左右对齐,避免贴边误触) Row({ space: 6 }) { Text('取消') .fontSize(16) .fontColor($r('app.color.brand')) + .padding({ left: 4, right: 4, top: 8, bottom: 8 }) .onClick(() => { router.back(); }) @@ -292,11 +332,13 @@ struct EventEditPage { .fontSize(16) .fontColor(this.isSaving ? $r('app.color.text_hint') : $r('app.color.brand')) .fontWeight(FontWeight.Medium) + .padding({ left: 4, right: 4, top: 8, bottom: 8 }) .onClick(() => { this.save(); }) } .width('100%') + .padding({ left: 16, right: 16 }) Scroll() { Column({ space: 14 }) { @@ -327,7 +369,7 @@ struct EventEditPage { .borderRadius(12) .backgroundColor($r('app.color.card_bg')) - // 时间卡片 + // 时间卡片(单击任一行:底部弹层中日期+时间一次选完) Column({ space: 10 }) { this.timeRow('开始', true) Divider().color($r('app.color.shadow_color')) @@ -338,6 +380,53 @@ struct EventEditPage { .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 }) { + this.repeatChip('none', '不重复') + this.repeatChip('daily', '每天') + this.repeatChip('workday', '工作日') + this.repeatChip('weekly', '每周') + this.repeatChip('monthly', '每月') + this.repeatChip('yearly', '每年') + if (this.repeatMode === 'custom') { + // 服务器来的复杂规则(INTERVAL/COUNT 等)标记为自定义,保存时原样保留 + this.repeatChip('custom', '自定义') + } + } + .width('100%') + } + .alignItems(HorizontalAlign.Start) + .width('100%') + .padding(14) + .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 }) { + this.reminderChip(0) + this.reminderChip(5) + this.reminderChip(10) + this.reminderChip(15) + this.reminderChip(30) + this.reminderChip(60) + this.reminderChip(1440) + } + .width('100%') + } + .alignItems(HorizontalAlign.Start) + .width('100%') + .padding(14) + .borderRadius(12) + .backgroundColor($r('app.color.card_bg')) + // 日历本选择 Column({ space: 8 }) { Text('日历本') @@ -424,6 +513,12 @@ struct EventEditPage { .height('100%') .padding({ top: 12 }) .backgroundColor($r('app.color.page_bg')) + .bindSheet($$this.pickerShow, this.dateTimePickerSheet(), { + height: 380, + showClose: false, + dragBar: true, + backgroundColor: $r('app.color.card_bg') + }) } @Builder @@ -433,32 +528,102 @@ struct EventEditPage { .fontSize(14) .fontColor($r('app.color.text_secondary')) .width(36) - Text(this.fmtDate(isStart ? this.startMs : this.endMs)) + Text(this.allDay + ? this.fmtDate(isStart ? this.startMs : this.endMs) + : `${this.fmtDate(isStart ? this.startMs : this.endMs)} ${this.fmtTime(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() + .layoutWeight(1) + Text('›') + .fontSize(16) + .fontColor($r('app.color.text_hint')) } .width('100%') - .padding({ left: 8, right: 8, top: 8, bottom: 8 }) + .padding({ left: 8, right: 8, top: 10, bottom: 10 }) + .onClick(() => { + this.openPicker(isStart); + }) + } + + @Builder + repeatChip(mode: string, label: string) { + Text(label) + .fontSize(12) + .fontColor(this.repeatMode === mode + ? $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.repeatMode === mode ? $r('app.color.brand') : $r('app.color.chip_off_bg')) + .onClick(() => { + this.repeatMode = mode; + }) + } + + @Builder + reminderChip(minutes: number) { + Text(this.reminderLabel(minutes)) + .fontSize(12) + .fontColor(this.reminderMin === minutes + ? $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.reminderMin === minutes ? $r('app.color.brand') : $r('app.color.chip_off_bg')) + .onClick(() => { + this.reminderMin = minutes; + }) + } + + /** 开始/结束时间选择弹层:左侧日期、右侧时间(全天时只有日期),一次确定 */ + @Builder + dateTimePickerSheet() { + Column({ space: 14 }) { + Text(this.pickerIsEnd ? '选择结束时间' : '选择开始时间') + .fontSize(16) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + .width('100%') + .textAlign(TextAlign.Center) + .padding({ top: 12 }) + Row({ space: 6 }) { + DatePicker({ + start: new Date(2000, 0, 1), + end: new Date(2050, 11, 31), + selected: this.pickDate + }) + .onDateChange((value: Date) => { + this.pickDate = value; + }) + .layoutWeight(1) + .height(210) + if (!this.allDay) { + TimePicker({ + selected: new Date(2000, 0, 1, this.pickHour, this.pickMin) + }) + .onChange((value: TimePickerResult) => { + this.pickHour = value.hour; + this.pickMin = value.minute; + }) + .layoutWeight(1) + .height(210) + } + } + .width('100%') + + Button(this.allDay ? '确定日期' : '确定') + .width('100%') + .height(44) + .borderRadius(12) + .fontSize(15) + .backgroundColor($r('app.color.brand')) + .onClick(() => { + this.applyPicker(); + this.pickerShow = false; + }) + } + .width('100%') + .padding({ left: 16, right: 16, bottom: 24 }) } } @@ -467,8 +632,15 @@ class CalendarDataBridge { static async loadWritableSources(context: common.Context): Promise { const result: CalSourceWithHref[] = []; const accounts: DavAccount[] = await AccountStore.loadAll(context); + const manualKeys: string[] = await AppSettings.getManualReadonlyKeys(context); for (const acc of accounts) { for (let i = 0; i < acc.calendarHrefs.length; i++) { + // 只读日历本(服务器无写权限,或用户手动标记只读)不可选 + const detected: boolean = acc.calendarWritable.length > i + ? acc.calendarWritable[i] !== '0' : true; + if (!detected || manualKeys.includes(`${acc.id}_${i}`)) { + continue; + } const s = new CalSourceWithHref(); s.calKey = `${acc.id}_${i}`; let name: string = i < acc.calendarNames.length ? acc.calendarNames[i] : ''; @@ -479,6 +651,7 @@ class CalendarDataBridge { let color: string = i < acc.calendarColors.length ? AccountStore.normalizeColor(acc.calendarColors[i]) : ''; s.color = color !== '' ? color : BookPalette.colorFor(i); s.href = acc.calendarHrefs[i]; + s.writable = true; result.push(s); } } diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 6a21400..f446ab8 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -1,7 +1,7 @@ // entry/src/main/ets/pages/Index.ets // 同步日历主界面:月视图(左右滑动翻月)/ 周视图 / 日视图 / 日程列表 // 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天" -import { router } from '@kit.ArkUI'; +import { mediaquery, router } from '@kit.ArkUI'; import { common } from '@kit.AbilityKit'; import { BusinessError, pasteboard } from '@kit.BasicServicesKit'; import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore'; @@ -15,6 +15,7 @@ import { AppSettings } from '../common/AppSettings'; import { EventDb, LocalEvent } from '../common/EventDb'; import { RruleUtil } from '../common/RruleUtil'; import { IcsUtil } from '../common/IcsUtil'; +import { SystemCalendarImport } from '../common/SystemCalendarImport'; /** 月视图单元格 */ class MonthCell { @@ -43,6 +44,8 @@ struct Index { @State syncing: boolean = false; @State loading: boolean = true; @State menuOpen: boolean = false; // 顶部 ≡ 下拉菜单 + @State isLandscape: boolean = false; // 横屏:月视图切左右双栏(左月历/右当日日程) + private landscapeListener: mediaquery.MediaQueryListener | null = null; private accounts: DavAccount[] = []; private sources: CalSource[] = []; private swiperController: SwiperController = new SwiperController(); @@ -62,6 +65,7 @@ struct Index { this.displayMonth = now.getMonth(); this.selectedDate = this.startOfDay(now.getTime()); this.rebuildPages(); + this.initLandscapeListener(); this.initPermissionAndLoad(); } @@ -70,6 +74,20 @@ struct Index { clearInterval(this.autoSyncTimer); this.autoSyncTimer = -1; } + if (this.landscapeListener !== null) { + this.landscapeListener.off('change'); + this.landscapeListener = null; + } + } + + /** 监听横竖屏:旋转后 isLandscape 驱动月视图在上下/左右布局间切换 */ + private initLandscapeListener(): void { + this.landscapeListener = + this.getUIContext().getMediaQuery().matchMediaSync('(orientation: landscape)'); + this.isLandscape = this.landscapeListener.matches; + this.landscapeListener.on('change', (result: mediaquery.MediaQueryResult) => { + this.isLandscape = result.matches; + }); } /** 从设置页/账号页返回时刷新(同步间隔、系统日历开关立即生效),并处理编辑账号后的待同步 */ @@ -329,6 +347,12 @@ struct Index { } let ok: number = 0; let failMsg: string = ''; + // 备份模式:先把系统本地日历导入目标日历本(幂等),再随正常同步推送上服务器 + try { + await SystemCalendarImport.importIfNeeded(context); + } catch (err) { + // 导入失败不影响正常同步 + } for (const acc of this.accounts) { if (acc.type !== TYPE_CALDAV) { continue; @@ -373,15 +397,53 @@ struct Index { } private openEvent(e: DisplayEvent): void { - if (e.isSystem) { - this.getUIContext().getPromptAction() - .showToast({ message: '系统日历日程,请在系统日历 App 中编辑' }); + // 只读日历本或系统日历日程:无法保存修改,直接显示详情 + if (e.isSystem || !e.writable) { + this.showEventDetail(e); return; } AppStorage.setOrCreate('pendingEventId', e.id); router.pushUrl({ url: 'pages/EventEditPage' }); } + /** 只读日程详情弹窗 */ + private showEventDetail(e: DisplayEvent): void { + const lines: string[] = []; + if (e.isAllDay) { + lines.push(`时间:全天 ${this.fmtDateCn(e.startTime)}`); + if (this.spansDays(e)) { + lines.push(` ~ ${this.fmtDateCn(e.endTime)}`); + } + } else if (this.spansDays(e)) { + lines.push(`时间:${this.fmtDateCn(e.startTime)} ${this.fmtTime(e.startTime)}`); + lines.push(` ~ ${this.fmtDateCn(e.endTime)} ${this.fmtTime(e.endTime)}`); + } else { + lines.push(`时间:${this.fmtDateCn(e.startTime)} ${this.fmtTime(e.startTime)} ~ ${this.fmtTime(e.endTime)}`); + } + if (e.location !== '') { + lines.push(`地点:${e.location}`); + } + if (e.recurring) { + lines.push('重复:是'); + } + if (e.calName !== '') { + lines.push(`日历本:${e.calName}${e.isSystem ? '' : '(只读)'}`); + } + if (e.description !== '') { + lines.push(`备注:${e.description}`); + } + this.getUIContext().showAlertDialog({ + title: e.title === '' ? '(无标题)' : e.title, + message: lines.join('\n'), + autoCancel: true, + alignment: DialogAlignment.Center, + primaryButton: { + value: '关闭', + action: (): void => {} + } + }); + } + private addEvent(): void { AppStorage.setOrCreate('pendingEventId', 0); AppStorage.setOrCreate('pendingEventDate', this.selectedDate); @@ -707,65 +769,109 @@ struct Index { @Builder monthBody() { - Column() { - // 星期表头 + if (this.isLandscape) { + // 横屏(平板适配):左右双栏——左侧月历,右侧当日日程 Row() { - ForEach(WEEK_LABELS, (w: string) => { - Text(w) - .fontSize(12) - .fontColor($r('app.color.text_hint')) - .textAlign(TextAlign.Center) - .layoutWeight(1) - }, (w: string) => w) + Column() { + this.monthWeekHeader() + this.monthSwiper() + } + .layoutWeight(3) + .height('100%') + + Divider() + .vertical(true) + .height('92%') + .strokeWidth(1) + .color($r('app.color.shadow_color')) + + Column() { + this.dayPanelHeader() + this.eventList() + } + .layoutWeight(2) + .height('100%') } .width('100%') - .padding({ left: 12, right: 12 }) - - // 三页月格,左右滑动切换月份 - Swiper(this.swiperController) { - ForEach(this.monthPages, (cells: MonthCell[]) => { - Column({ space: 2 }) { - ForEach(this.chunkCells(cells), (week: MonthCell[], idx: number) => { - Row({ space: 2 }) { - ForEach(week, (cell: MonthCell) => { - this.dayCell(cell) - }, (cell: MonthCell) => `${cell.dateMs}_${cell.dateMs === this.selectedDate}`) - } - .width('100%') - }, (week: MonthCell[], idx: number) => String(idx)) - } - .width('100%') - }, (cells: MonthCell[]) => String(cells[0].dateMs)) + .layoutWeight(1) + } else { + // 竖屏:月历在上、当日日程在下(保持原布局) + Column() { + this.monthWeekHeader() + this.monthSwiper() + this.dayPanelHeader() + this.eventList() } - .index(1) - .loop(false) - .indicator(false) .width('100%') - .onChange((index: number) => { - this.handleSwiperChange(index); - }) + .layoutWeight(1) + } + } - // 当日日程列表 - Row({ space: 8 }) { - Text(this.fmtDateCn(this.selectedDate)) - .fontSize(13) - .fontWeight(FontWeight.Medium) - .fontColor($r('app.color.text_secondary')) - Blank() - Text(this.fmtMonthTitle()) + /** 月视图星期表头 */ + @Builder + monthWeekHeader() { + Row() { + ForEach(WEEK_LABELS, (w: string) => { + Text(w) .fontSize(12) .fontColor($r('app.color.text_hint')) - } - .width('100%') - .padding({ left: 20, right: 20, top: 6 }) - .gesture(LongPressGesture().onAction(() => { - this.showOccurrenceDebug(this.selectedDate); - })) - - this.eventList() + .textAlign(TextAlign.Center) + .layoutWeight(1) + }, (w: string) => w) } .width('100%') - .layoutWeight(1) + .padding({ left: 12, right: 12 }) + } + + /** 月视图三页 Swiper(横屏时占满剩余高度并均分每周行,竖屏保持自然高度) */ + @Builder + monthSwiper() { + Swiper(this.swiperController) { + ForEach(this.monthPages, (cells: MonthCell[]) => { + Column({ space: 2 }) { + ForEach(this.chunkCells(cells), (week: MonthCell[], idx: number) => { + Row({ space: 2 }) { + ForEach(week, (cell: MonthCell) => { + this.dayCell(cell) + }, (cell: MonthCell) => `${cell.dateMs}_${cell.dateMs === this.selectedDate}`) + } + .width('100%') + .layoutWeight(this.isLandscape ? 1 : 0) + }, (week: MonthCell[], idx: number) => String(idx)) + } + .width('100%') + // 注意:不要给页面设百分比高度——竖屏时 Swiper 是自然高度, + // 子组件 height('100%') 对 auto 父容器解析为 0,整月网格会塌陷 + }, (cells: MonthCell[]) => String(cells[0].dateMs)) + } + .index(1) + .loop(false) + .indicator(false) + .width('100%') + .layoutWeight(this.isLandscape ? 1 : 0) + .onChange((index: number) => { + this.handleSwiperChange(index); + }) + } + + /** 当日日程标题行(长按可触发重复日程调试) */ + @Builder + dayPanelHeader() { + Row({ space: 8 }) { + Text(this.fmtDateCn(this.selectedDate)) + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_secondary')) + Blank() + Text(this.fmtMonthTitle()) + .fontSize(12) + .fontColor($r('app.color.text_hint')) + } + .width('100%') + .padding({ left: 20, right: 20, top: 6 }) + .gesture(LongPressGesture().onAction(() => { + this.showOccurrenceDebug(this.selectedDate); + })) } /** 调试(临时):长按月视图日期标题,检查重复日程在该日的首次发生情况 */ @@ -890,48 +996,144 @@ struct Index { @Builder weekBody() { - Column() { - Row({ space: 16 }) { - Blank() - Text('本周') - .fontSize(15) - .fontWeight(FontWeight.Bold) - .fontColor($r('app.color.text_primary')) - .onClick(() => { - this.goThisWeek(); + if (this.isLandscape) { + // 横屏(平板适配):左侧一周日期,右侧选中日日程 + Row() { + Column() { + Text('本周') + .fontSize(15) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + .width('100%') + .textAlign(TextAlign.Center) + .padding({ top: 4, bottom: 8 }) + .onClick(() => { + this.goThisWeek(); + }) + this.weekStripVertical() + } + .layoutWeight(1) + .height('100%') + + Divider() + .vertical(true) + .height('92%') + .strokeWidth(1) + .color($r('app.color.shadow_color')) + + Column() { + this.dayPanelHeader() + this.eventList() + } + .layoutWeight(2) + .height('100%') + } + .width('100%') + .layoutWeight(1) + .gesture( + SwipeGesture({ direction: SwipeDirection.Horizontal }) + .onAction((event: GestureEvent) => { + if (Math.abs(event.angle) > 90) { + this.switchWeek(1); + } else { + this.switchWeek(-1); + } }) - Blank() + ) + } else { + // 竖屏:保持原上下结构 + Column() { + Row({ space: 16 }) { + Blank() + Text('本周') + .fontSize(15) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + .onClick(() => { + this.goThisWeek(); + }) + Blank() + } + .width('100%') + .padding({ left: 20, right: 20, top: 4, bottom: 4 }) + + this.weekStrip() + + Row({ space: 8 }) { + Text(this.fmtDateCn(this.selectedDate)) + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_secondary')) + Blank() + } + .width('100%') + .padding({ left: 20, right: 20, top: 8 }) + + this.eventList() } .width('100%') - .padding({ left: 20, right: 20, top: 4, bottom: 4 }) - - this.weekStrip() - - Row({ space: 8 }) { - Text(this.fmtDateCn(this.selectedDate)) - .fontSize(13) - .fontWeight(FontWeight.Medium) - .fontColor($r('app.color.text_secondary')) - Blank() - } - .width('100%') - .padding({ left: 20, right: 20, top: 8 }) - - this.eventList() + .layoutWeight(1) + .gesture( + SwipeGesture({ direction: SwipeDirection.Horizontal }) + .onAction((event: GestureEvent) => { + // 左滑角度约 ±180(|angle|>90)→ 下一周;右滑约 0° → 上一周(与月视图 Swiper 方向一致) + if (Math.abs(event.angle) > 90) { + this.switchWeek(1); + } else { + this.switchWeek(-1); + } + }) + ) } + } + + /** 周视图竖排日期条(横屏左栏:周一~周日从上到下,可上下滚动) */ + @Builder + weekStripVertical() { + Scroll() { + Column({ space: 2 }) { + ForEach(this.weekStripDays(), (cell: MonthCell) => { + Row({ space: 10 }) { + Text(WEEK_LABELS[(new Date(cell.dateMs).getDay() + 6) % 7]) + .fontSize(12) + .fontColor(cell.dateMs === this.selectedDate + ? $r('app.color.text_secondary') : $r('app.color.text_hint')) + .width(18) + .textAlign(TextAlign.Center) + Text(String(cell.day)) + .fontSize(15) + .fontWeight(cell.dateMs === this.selectedDate || cell.isToday + ? FontWeight.Bold : FontWeight.Normal) + .fontColor(cell.dateMs === this.selectedDate + ? $r('app.color.button_text') : $r('app.color.text_primary')) + .width(32) + .height(32) + .textAlign(TextAlign.Center) + .borderRadius(16) + .backgroundColor(cell.dateMs === this.selectedDate + ? $r('app.color.selected_bg') + : (cell.isToday ? $r('app.color.today_bg') : Color.Transparent)) + } + .width('100%') + .height(44) + .borderRadius(10) + .padding({ left: 10 }) + .backgroundColor(cell.dateMs === this.selectedDate + ? $r('app.color.chip_off_bg') : Color.Transparent) + .onClick(() => { + this.selectedDate = cell.dateMs; + }) + }, (cell: MonthCell) => `${cell.dateMs}_${cell.dateMs === this.selectedDate}`) + } + .width('100%') + .padding({ left: 8, right: 8, top: 2, bottom: 8 }) + .constraintSize({ minHeight: '100%' }) + } + .scrollBar(BarState.Off) + .edgeEffect(EdgeEffect.Spring) + .align(Alignment.Top) .width('100%') .layoutWeight(1) - .gesture( - SwipeGesture({ direction: SwipeDirection.Horizontal }) - .onAction((event: GestureEvent) => { - // 左滑角度约 ±180(|angle|>90)→ 下一周;右滑约 0° → 上一周(与月视图 Swiper 方向一致) - if (Math.abs(event.angle) > 90) { - this.switchWeek(1); - } else { - this.switchWeek(-1); - } - }) - ) } /** 单日日程列表(周视图使用) */ @@ -952,7 +1154,7 @@ struct Index { } } .width('100%') - .padding({ left: 20, right: 20, top: 4, bottom: 90 }) + .padding({ left: 20, right: 20, top: 4, bottom: 24 }) .constraintSize({ minHeight: '100%' }) } .layoutWeight(1) @@ -1000,7 +1202,7 @@ struct Index { .layoutWeight(1) .scrollBar(BarState.Auto) .edgeEffect(EdgeEffect.Spring) - .padding({ left: 20, right: 20, top: 6, bottom: 90 }) + .padding({ left: 20, right: 20, top: 6, bottom: 24 }) .cachedCount(8) } @@ -1097,7 +1299,7 @@ struct Index { } } .width('100%') - .padding({ left: 20, right: 20, top: 8, bottom: 90 }) + .padding({ left: 20, right: 20, top: 8, bottom: 24 }) .constraintSize({ minHeight: '100%' }) } .layoutWeight(1) @@ -1196,7 +1398,7 @@ struct Index { .borderRadius(6) .backgroundColor($r('app.color.chip_off_bg')) } - // 所属日历本:最右侧、垂直居中,颜色同日历本 + // 所属日历本:最右侧、垂直居中,颜色同日历本;只读日历本加删除线标识 if (e.calName !== '') { Text(e.calName) .fontSize(11) @@ -1204,6 +1406,7 @@ struct Index { .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .constraintSize({ maxWidth: '30%' }) + .decoration({ type: e.writable ? TextDecorationType.None : TextDecorationType.LineThrough }) } } .alignItems(VerticalAlign.Center) @@ -1221,23 +1424,26 @@ struct Index { dayCell(cell: MonthCell) { Column({ space: 2 }) { Text(String(cell.day)) - .fontSize(13) + .fontSize(this.isLandscape ? 12 : 13) .fontWeight(cell.isToday || cell.dateMs === this.selectedDate ? FontWeight.Bold : FontWeight.Normal) .fontColor(!cell.inMonth ? $r('app.color.text_hint') : (cell.dateMs === this.selectedDate ? $r('app.color.button_text') : $r('app.color.text_primary'))) - .width(26) - .height(26) + .width(this.isLandscape ? 22 : 26) + .height(this.isLandscape ? 22 : 26) .textAlign(TextAlign.Center) - .borderRadius(13) + .borderRadius(this.isLandscape ? 11 : 13) .backgroundColor(cell.dateMs === this.selectedDate ? $r('app.color.selected_bg') : (cell.isToday ? $r('app.color.today_bg') : Color.Transparent)) - Text(cell.lunar) - .fontSize(8) - .fontColor(cell.dateMs === this.selectedDate - ? $r('app.color.text_secondary') : $r('app.color.text_hint')) - .maxLines(1) + if (!this.isLandscape) { + // 横屏高度有限,省略农历腾出空间 + Text(cell.lunar) + .fontSize(8) + .fontColor(cell.dateMs === this.selectedDate + ? $r('app.color.text_secondary') : $r('app.color.text_hint')) + .maxLines(1) + } } .layoutWeight(1) .padding({ top: 4, bottom: 4 }) diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index c94c40c..b9c56a9 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -1,19 +1,32 @@ // entry/src/main/ets/pages/SettingsPage.ets -// 设置页:系统日历混合显示开关 + 自动同步间隔 +// 设置页:系统日历模式(仅显示/备份到 CalDAV)+ 混合显示开关 + 自动同步间隔 + 后台同步 import { router } from '@kit.ArkUI'; import { common } from '@kit.AbilityKit'; import { AppSettings } from '../common/AppSettings'; import { BackgroundSyncService } from '../common/BackgroundSyncService'; +import { AccountStore, DavAccount } from '../common/AccountStore'; import { LogUtil } from '../common/LogUtil'; const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60]; +/** 日历本选项(备份目标 / 只读标记共用) */ +class BackupTarget { + calKey: string = ''; + label: string = ''; + serverWritable: boolean = true; // 服务器探测结果 +} + @Entry @Component struct SettingsPage { @State showSystem: boolean = true; @State intervalMinutes: number = 1; @State backgroundSync: boolean = false; + @State sysMode: string = 'display'; // display | backup + @State backupKey: string = ''; + @State backupTargets: BackupTarget[] = []; + @State allBooks: BackupTarget[] = []; // 全部 DAV 日历本(只读标记管理用) + @State manualKeys: string[] = []; // 手动标记只读的 calKey private context?: common.Context; aboutToAppear(): void { @@ -32,6 +45,128 @@ struct SettingsPage { AppSettings.getBackgroundSync(ctx).then((v: boolean): void => { this.backgroundSync = v; }); + AppSettings.getSysCalMode(ctx).then((v: string): void => { + this.sysMode = v; + }); + AppSettings.getManualReadonlyKeys(ctx).then((v: string[]): void => { + this.manualKeys = v; + }); + this.loadBackupSettings(); + this.loadAllBooks(); + } + + /** 加载全部 DAV 日历本(含探测到的写权限,只读标记管理用) */ + private async loadAllBooks(): Promise { + if (this.context === undefined) { + return; + } + const accounts: DavAccount[] = await AccountStore.loadAll(this.context); + const books: BackupTarget[] = []; + for (const acc of accounts) { + if (acc.type !== 'caldav') { + continue; + } + for (let i = 0; i < acc.calendarHrefs.length; i++) { + const b = new BackupTarget(); + b.calKey = `${acc.id}_${i}`; + const bookName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本 ${i + 1}`; + b.label = `${acc.name} · ${bookName}`; + b.serverWritable = acc.calendarWritable.length > i ? acc.calendarWritable[i] !== '0' : true; + books.push(b); + } + } + this.allBooks = books; + } + + /** 手动标记/取消只读 */ + private async toggleManualBook(b: BackupTarget): Promise { + if (this.context === undefined) { + return; + } + const list: string[] = [...this.manualKeys]; + const idx: number = list.indexOf(b.calKey); + if (idx >= 0) { + list.splice(idx, 1); + } else { + list.push(b.calKey); + } + this.manualKeys = list; + await AppSettings.setManualReadonlyKeys(this.context, list); + await this.loadBackupSettings(); // 备份目标候选同步排除 + this.getUIContext().getPromptAction().showToast({ + message: idx >= 0 ? '已恢复为可写,返回首页生效' : '已标记为只读,返回首页生效' + }); + } + + /** 日历本当前只读状态文案 */ + private bookStateLabel(b: BackupTarget): string { + if (this.manualKeys.includes(b.calKey)) { + return '只读(手动)'; + } + return b.serverWritable ? '可写' : '只读'; + } + + /** 加载备份目标候选(可写 DAV 日历本)+ 当前选择 */ + private async loadBackupSettings(): Promise { + if (this.context === undefined) { + return; + } + const accounts: DavAccount[] = await AccountStore.loadAll(this.context); + const targets: BackupTarget[] = []; + for (const acc of accounts) { + if (acc.type !== 'caldav') { + continue; + } + for (let i = 0; i < acc.calendarHrefs.length; i++) { + const writable: boolean = acc.calendarWritable.length > i + ? acc.calendarWritable[i] !== '0' : true; + if (!writable) { + continue; + } + const t = new BackupTarget(); + t.calKey = `${acc.id}_${i}`; + const bookName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本 ${i + 1}`; + t.label = `${acc.name} · ${bookName}`; + t.serverWritable = writable; + targets.push(t); + } + } + // 手动标记只读的本不可作为备份目标 + const manual: string[] = await AppSettings.getManualReadonlyKeys(this.context); + this.backupTargets = targets.filter((t: BackupTarget): boolean => !manual.includes(t.calKey)); + this.backupTargets = targets; + const saved: string = await AppSettings.getBackupCalKey(this.context); + this.backupKey = targets.some((t: BackupTarget): boolean => t.calKey === saved) ? saved : ''; + } + + private async saveSysMode(mode: string): Promise { + if (this.context === undefined) { + return; + } + this.sysMode = mode; + await AppSettings.setSysCalMode(this.context, mode); + if (mode === 'backup' && this.backupKey === '') { + // 自动选中第一个可写日历本 + if (this.backupTargets.length > 0) { + this.backupKey = this.backupTargets[0].calKey; + await AppSettings.setBackupCalKey(this.context, this.backupKey); + } + } + this.getUIContext().getPromptAction().showToast({ + message: mode === 'backup' + ? '已开启备份:下次同步时把系统本地日程导入所选日历本' + : '已切换为仅显示:不再把系统日程备份到 CalDAV' + }); + } + + private async saveBackupTarget(calKey: string): Promise { + if (this.context === undefined) { + return; + } + this.backupKey = calKey; + await AppSettings.setBackupCalKey(this.context, calKey); + this.getUIContext().getPromptAction() + .showToast({ message: '备份目标已更新,下次同步生效' }); } private async saveShowSystem(value: boolean): Promise { @@ -96,7 +231,125 @@ struct SettingsPage { Scroll() { Column({ space: 12 }) { - // 系统日历混合显示 + // 系统日历模式:仅显示 / 备份到 CalDAV + Column({ space: 8 }) { + 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) + } + .width('100%') + Select([{ value: '仅显示(不做备份)' }, { value: '备份到 CalDAV 日历本' }] as SelectOption[]) + .selected(this.sysMode === 'backup' ? 1 : 0) + .value(this.sysMode === 'backup' ? '备份到 CalDAV 日历本' : '仅显示(不做备份)') + .fontColor($r('app.color.text_primary')) + .font({ size: 14 }) + .optionFont({ size: 14 }) + .selectedOptionFont({ size: 14 }) + .width('100%') + .onSelect((index: number) => { + const mode: string = index === 1 ? 'backup' : 'display'; + if (mode !== this.sysMode) { + this.saveSysMode(mode); + } + }) + if (this.sysMode === 'backup') { + Text(this.backupTargets.length > 0 + ? '备份目标(可写日历本):导入后随同步上传服务器,换机/丢失也有备份' + : '没有可写的 CalDAV 日历本,请先添加账号或检查日历本权限') + .fontSize(12) + .fontColor(this.backupTargets.length > 0 + ? $r('app.color.text_secondary') : $r('app.color.error')) + .width('100%') + if (this.backupTargets.length > 0) { + Select(this.backupTargets.map((t: BackupTarget): SelectOption => { + return { value: t.label } as SelectOption; + }) as SelectOption[]) + .selected(this.backupTargets.findIndex((t: BackupTarget): boolean => t.calKey === this.backupKey)) + .value(this.backupTargets.find((t: BackupTarget): boolean => t.calKey === this.backupKey)?.label + ?? '请选择日历本') + .fontColor($r('app.color.text_primary')) + .font({ size: 14 }) + .optionFont({ size: 14 }) + .selectedOptionFont({ size: 14 }) + .width('100%') + .onSelect((index: number) => { + if (index >= 0 && index < this.backupTargets.length) { + this.saveBackupTarget(this.backupTargets[index].calKey); + } + }) + } + } + } + .alignItems(HorizontalAlign.Start) + .width('100%') + .padding(14) + .borderRadius(12) + .backgroundColor($r('app.color.card_bg')) + .border({ width: 1, color: $r('app.color.shadow_color') }) + + // 日历本只读标记(部分服务器不在 CalDAV 层拒绝写入,自动探测无法区分时手动标记) + Column({ space: 8 }) { + 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) + } + .width('100%') + if (this.allBooks.length === 0) { + Text('暂无 CalDAV 日历本') + .fontSize(12) + .fontColor($r('app.color.text_hint')) + .width('100%') + } + ForEach(this.allBooks, (b: BackupTarget) => { + Row({ space: 8 }) { + Text(b.label) + .fontSize(13) + .fontColor($r('app.color.text_primary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + Text(this.bookStateLabel(b)) + .fontSize(11) + .fontColor(this.bookStateLabel(b) === '可写' + ? $r('app.color.success') : $r('app.color.error')) + .padding({ left: 8, right: 8, top: 3, bottom: 3 }) + .borderRadius(10) + .backgroundColor(this.bookStateLabel(b) === '可写' + ? $r('app.color.success_bg') : $r('app.color.error_bg')) + } + .width('100%') + .padding({ top: 6, bottom: 6 }) + .onClick(() => { + this.toggleManualBook(b); + }) + }, (b: BackupTarget) => `${b.calKey}_${this.manualKeys.includes(b.calKey) ? 1 : 0}`) + } + .alignItems(HorizontalAlign.Start) + .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('混合显示系统日历') diff --git a/entry/src/main/ets/syncwork/SyncWorkAbility.ets b/entry/src/main/ets/syncwork/SyncWorkAbility.ets index 526f3ec..677c3f8 100644 --- a/entry/src/main/ets/syncwork/SyncWorkAbility.ets +++ b/entry/src/main/ets/syncwork/SyncWorkAbility.ets @@ -7,6 +7,7 @@ import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore'; import { SyncEngine } from '../common/SyncEngine'; import { CardDataService } from '../common/CardDataService'; import { ReminderService } from '../common/ReminderService'; +import { SystemCalendarImport } from '../common/SystemCalendarImport'; import { LogUtil } from '../common/LogUtil'; export default class SyncWorkAbility extends WorkSchedulerExtensionAbility { @@ -24,6 +25,12 @@ export default class SyncWorkAbility extends WorkSchedulerExtensionAbility { try { const context = this.context; const accounts: DavAccount[] = await AccountStore.loadAll(context); + // 备份模式:先导入系统本地日历(幂等),再随同步推送 + try { + await SystemCalendarImport.importIfNeeded(context); + } catch (err) { + // 导入失败不影响正常同步 + } let ok: number = 0; for (const acc of accounts) { if (acc.type !== TYPE_CALDAV) { diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index 9dc7d9c..7f8e471 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -5,7 +5,8 @@ "description": "$string:module_desc", "mainElement": "EntryAbility", "deviceTypes": [ - "phone" + "phone", + "tablet" ], "requestPermissions": [ { @@ -58,6 +59,7 @@ "startWindowIcon": "$media:startIcon", "startWindowBackground": "$color:start_window_background", "exported": true, + "orientation": "auto_rotation_restricted", "backgroundModes": [ "dataTransfer" ],