From ed26da8dc41a3fd0e3c93ce0d6ce946fa5cca71e Mon Sep 17 00:00:00 2001 From: laoyang Date: Tue, 13 Oct 2026 12:43:26 +0800 Subject: [PATCH] =?UTF-8?q?0.0.7=EF=BC=9ACalDAV=20=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8F=91=E7=8E=B0=20+=20=E4=BA=BA=E8=AF=9D?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA=20+=20=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E5=85=AC=E5=BC=80=E6=97=A5=E5=8E=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:用户反馈(Nextcloud/内网)——DAVx5 用 CalDAV 地址能完整读取,换本应用 读不出日历;用"分享链接"绑定时提示服务器返回 405。 定位:不是协议问题,而是"填入的 URL 不是 CalDAV 端点"。 ① 我们的发现=PROPFIND+Depth:1 只认 resourcetype 含 calendar 的集合;填 DAV 根 /remote.php/dav 只能看到 principals/calendars/addressbooks → "未发现日历本"。 ② 分享链接(/apps/calendar/p/、/s/)是网页路由 → PROPFIND 405。 ③ 全工程此前无 .well-known 自动发现(grep 无命中)→ 必须手填完整路径, 而 DAVx5 内置自动发现,故"DAVx5 能用、我们不能"。 实现(新增 common/DavDiscovery.ets): 1. .well-known/caldav 自动发现:207/200 直接用;301/302/307/308/401 读 Location 2. 智能补全:按 Nextcloud/ownCloud/Radicale/Baikal 等常见路径生成候选并逐个探测, 保留子目录前缀(填 https://h/nextcloud → /nextcloud/remote.php/dav/calendars//) 3. 分享链接识别:/apps/calendar/p/、/s/、/index.php/s/ 直接拦下并给改法 4. OPTIONS 能力探测:读 DAV/Allow 头,区分"不是 DAV 端点"与"服务端挡了方法" 5. 状态码人话化:405/401/403/404/跳转/网络失败各有可操作指引; "未发现日历本"区分"地址差一层"(给出补全后的正确地址)与"确实为空" 6. 只读公开日历:识别 public-calendars 链接;只读由既有 calendarWritable 探测自动标记 接入三处: - CalendarListPage.fetchCalendars / EditAccountPage:先 resolve() 定位端点, 命中则自动修正 serverUrl(并落库),失败则直接给人话原因 - AddAccountPage:连接前拦分享链接;probeServer 遇 405 用 OPTIONS 判定并给指引; 地址输入框提示同步更新 设计要点:发现顺序=原地址优先(保持既有行为、不破坏已能用的用户)→ .well-known → 常见候选;单个候选失败不抛错;ArkTS 静态方法内禁用 this → 统一用类名调用。 验证:编译通过;离线用例覆盖分类/完整性判定/候选路径(4 种输入形态)全部通过。 版本:AppScope/app.json5 → 0.0.7 / 700 文档:新增《上架更新说明-0.0.6到0.0.7.md》;官网 sf/uh 补功能说明与使用指引、 版本号与日期更新、404 重建,已部署并线上核验。 打包:assembleApp release → verify-app success、profile type=release、 app-distribution-type=app_gallery、包内版本 700/0.0.7。 --- AppScope/app.json5 | 4 +- entry/src/main/ets/common/DavDiscovery.ets | 458 ++++++++++++++++++ entry/src/main/ets/pages/AddAccountPage.ets | 37 +- entry/src/main/ets/pages/CalendarListPage.ets | 37 +- entry/src/main/ets/pages/EditAccountPage.ets | 32 +- 5 files changed, 555 insertions(+), 13 deletions(-) create mode 100644 entry/src/main/ets/common/DavDiscovery.ets diff --git a/AppScope/app.json5 b/AppScope/app.json5 index 0d0ac19..ecf2899 100644 --- a/AppScope/app.json5 +++ b/AppScope/app.json5 @@ -2,8 +2,8 @@ "app": { "bundleName": "synccalendar.yangyq.net", "vendor": "yangyq", - "versionCode": 600, - "versionName": "0.0.6", + "versionCode": 700, + "versionName": "0.0.7", "buildVersion": "1", "icon": "$media:layered_image", "label": "$string:app_name" diff --git a/entry/src/main/ets/common/DavDiscovery.ets b/entry/src/main/ets/common/DavDiscovery.ets new file mode 100644 index 0000000..ba4a64c --- /dev/null +++ b/entry/src/main/ets/common/DavDiscovery.ets @@ -0,0 +1,458 @@ +// entry/src/main/ets/common/DavDiscovery.ets +// ⭐ CalDAV 地址自动发现与智能补全(0.0.7) +// +// 解决的用户问题(见《CalDAV排查指南-405与读不出日历.md》): +// 用户习惯只填域名或 DAV 根,而 Nextcloud 等要求填「日历家目录」 +// (/remote.php/dav/calendars/<用户名>/)。填错表现为"读不出日历"或 405。 +// DAVx5 能用是因为它内置 .well-known 自动发现 —— 本模块补上这一能力。 +// +// 发现顺序(逐级降级,任一成功即停): +// ① 用户填的地址原样探测(保持既有行为,兼容已能用的用户) +// ② .well-known/caldav(RFC 6764 标准;Nextcloud/Radicale/Baïkal 均支持) +// ③ 常见候选路径拼接(remote.php/dav/calendars/<用户名>/ 等) +// +// ⚠️ 设计约束: +// - 全部探测都是 PROPFIND + Depth:0,只判断「是不是能列出日历的端点」,不做副作用; +// - 单次探测失败不抛错(网络异常吞掉继续试下一个),保证不会因某个候选不可达而整体失败; +// - 最终由调用方用 listCalendars 校验(能列出 calendar 集合才算真成功)。 +import { http } from '@kit.NetworkKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { LogUtil } from './LogUtil'; + +/** 发现结果:拿到一个可用的"日历集合列表"端点 */ +export class DiscoverResult { + ok: boolean = false; + url: string = ''; // 最终可用的端点 URL + via: string = ''; // 来源:direct / well-known / candidate / public + readOnly: boolean = false; // 是否只读(公开日历分享);本版本仅识别,不做写入 + hint: string = ''; // 失败时给用户的可操作提示 +} + +/** 415/405 之外:用于判断"不是 DAV 端点"的状态码集合 */ +const NOT_DAV_CODES: number[] = [404, 405, 501]; + +export class DavDiscovery { + // ───────────────────────── 公开工具:URL 形态识别 ───────────────────────── + + /** 取 origin(scheme://host[:port]) */ + static originOf(url: string): string { + const m = /^https?:\/\/[^/]+/i.exec(url.trim()); + return m !== null ? m[0] : ''; + } + + /** 规范化:去掉末尾多余斜杠、去空白 */ + static tidy(url: string): string { + let s: string = url.trim(); + // 去掉查询与片段(发现阶段不保留) + s = s.replace(/[?#].*$/, ''); + while (s.endsWith('/') && s.length > DavDiscovery.originOf(s).length + 1) { + s = s.substring(0, s.length - 1); + } + return s; + } + + /** + * ⭐ 识别「网页分享链接」——这类地址对 PROPFIND 一律 405,必须提前拦下并提示。 + * 覆盖: + * - Nextcloud 日历分享页:/apps/calendar/p/ + * - Nextcloud 文件分享页:/s/、/index.php/s/ + * - Nextcloud 公开日历(只读,可用):/remote.php/dav/public-calendars/ + */ + static classify(url: string): string { + const u: string = url.trim(); + const path = u.replace(/^https?:\/\/[^/]+/i, ''); + if (/^\/apps\/calendar\/p\//i.test(path)) { + return 'share_calendar'; // 日历分享页面(网页) + } + if (/^\/(index\.php\/)?s\//i.test(path)) { + return 'share_file'; // 文件分享页面(网页) + } + if (/^\/remote\.php\/dav\/public-calendars\//i.test(path)) { + return 'public_caldav'; // 公开日历(DAV,只读) + } + if (/^\/remote\.php\/dav\/public-calendars$/i.test(path.replace(/\/$/, ''))) { + return 'public_caldav'; + } + return 'normal'; + } + + /** 分享链接 → 给用户的人话解释 */ + static shareHint(kind: string): string { + if (kind === 'share_calendar') { + return '这是 Nextcloud 的「日历分享链接」(网页地址),CalDAV 客户端不能直接使用。' + + '请在 Nextcloud 网页版「日历 → 左下角设置 → 目标日历 → 复制私有链接」,' + + '把得到的私有链接填到「服务器地址」。'; + } + if (kind === 'share_file') { + return '这是文件分享链接(网页地址),不是 CalDAV 地址。' + + '请改用日历的「私有链接」:Nextcloud 网页版「日历 → 设置 → 复制私有链接」。'; + } + return ''; + } + + /** + * 是否为"只可能返回 405/404 的网页地址"——用于在探测前直接拦截,省一次请求。 + */ + static isWebPage(url: string): boolean { + const k: string = DavDiscovery.classify(url); + return k === 'share_calendar' || k === 'share_file'; + } + + /** + * DAV 根 / 站点根 → 需要补全。用于判断是否值得走候选路径。 + * `/remote.php/dav` 或仅 origin 时,多半缺"日历家目录"这一层。 + */ + static looksIncomplete(url: string): boolean { + const path: string = DavDiscovery.tidy(url).replace(/^https?:\/\/[^/]+/i, '').replace(/\/$/, ''); + if (path === '' || path === '/') { + return true; // 只填了域名 + } + if (/\/remote\.php\/dav$/i.test(path)) { + return true; // DAV 根 + } + if (/\/remote\.php\/dav\/(calendars|addressbooks)$/i.test(path)) { + return true; // 少了用户名一层 + } + return false; + } + + // ───────────────────────── 探测原语 ───────────────────────── + + /** + * PROPFIND Depth:0 探测端点:只关心"能不能列、是不是 DAV"。 + * 不抛错:网络异常/状态码异常都返回可判定的结果。 + */ + static async probe(url: string, auth: string): Promise { + const body: string = + '' + + ''; + const httpRequest = http.createHttp(); + try { + const resp: http.HttpResponse = await httpRequest.request(url, { + method: 'PROPFIND' as http.RequestMethod, + header: { + 'Authorization': auth, + 'Content-Type': 'application/xml; charset=utf-8', + 'Depth': '0', + 'User-Agent': 'SyncCalendar/1.0' + }, + extraData: body, + connectTimeout: 8000, + readTimeout: 12000 + }); + LogUtil.write(`HTTP PROPFIND(发现) ${url} → ${resp.responseCode}`); + return resp.responseCode; + } catch (err) { + const e = err as BusinessError; + LogUtil.write(`PROPFIND(发现) ${url} 异常: ${e.code} - ${e.message}`); + return 0; // 0 = 网络层失败 + } finally { + httpRequest.destroy(); + } + } + + /** + * ⭐ OPTIONS 探测服务器的 DAV 能力: + * - 返回 `DAV:` 响应头 → 标准 WebDAV + * - 返回 `Allow` 里含 PROPFIND/REPORT → 支持 CalDAV 所需方法 + * 用于区分"这不是 DAV 端点"(如网页路由)与"服务端把方法挡了"。 + */ + static async optionsCapability(url: string, auth: string): Promise { + const cap = new DavCapability(); + const httpRequest = http.createHttp(); + try { + const resp: http.HttpResponse = await httpRequest.request(url, { + method: http.RequestMethod.OPTIONS, + header: { + 'Authorization': auth, + 'User-Agent': 'SyncCalendar/1.0' + }, + connectTimeout: 8000, + readTimeout: 12000 + }); + cap.status = resp.responseCode; + const headers = resp.header as Record; + if (headers !== undefined && headers !== null) { + // 头名大小写不统一,逐个大小写变体试 + const dav = headers['DAV'] ?? headers['Dav'] ?? headers['dav'] ?? ''; + const allow = headers['Allow'] ?? headers['allow'] ?? ''; + cap.davHeader = typeof dav === 'string' ? dav : ''; + cap.allow = typeof allow === 'string' ? allow : ''; + cap.isDav = cap.davHeader !== '' || /PROPFIND/i.test(cap.allow); + cap.supportsPropfind = /PROPFIND/i.test(cap.allow) || cap.isDav; + cap.supportsReport = /REPORT/i.test(cap.allow); + // Allow 明确列出但两者都没有 → 大概率不是 DAV + if (cap.allow !== '' && !cap.supportsPropfind) { + cap.isDav = false; + } + } + LogUtil.write(`OPTIONS ${url} → ${resp.responseCode},DAV=${cap.davHeader || '(无)'},Allow=${cap.allow || '(无)'}`); + return cap; + } catch (err) { + const e = err as BusinessError; + LogUtil.write(`OPTIONS ${url} 异常: ${e.code} - ${e.message}`); + return cap; + } finally { + httpRequest.destroy(); + } + } + + // ───────────────────────── 候选路径生成 ───────────────────────── + + /** + * 生成候选「日历家目录 / 集合列表」端点。 + * @param base 用户填入并规范化后的地址 + * @param username 用户名(用于 Nextcloud 的 calendars/<用户名>/ 等模式) + */ + static candidates(base: string, username: string): string[] { + const origin: string = DavDiscovery.originOf(base); + const out: string[] = []; + if (origin === '') { + return out; + } + const path: string = DavDiscovery.tidy(base).substring(origin.length); + // 站点可能部署在子目录(如 /nextcloud)——保留用户填的前缀,只补 DAV 相关部分 + // 例:填 https://h/nextcloud → 前缀 /nextcloud + let prefix: string = ''; + const idx: number = path.toLowerCase().indexOf('/remote.php'); + if (idx >= 0) { + prefix = path.substring(0, idx); + } else if (path !== '' && path !== '/') { + prefix = path.replace(/\/$/, ''); + } + const u: string = encodeURIComponent(username); + const push = (p: string): void => { + const full: string = origin + p; + if (out.indexOf(full) < 0 && DavDiscovery.tidy(full) !== DavDiscovery.tidy(base)) { + out.push(full); + } + }; + // Nextcloud / ownCloud + push(`${prefix}/remote.php/dav/calendars/${u}/`); + push(`${prefix}/remote.php/dav`); + // Radicale(通常直接就是 /<用户名>/) + push(`${prefix}/${u}/`); + // Baïkal + push(`${prefix}/dav.php/calendars/${u}/`); + // 通用:直接在根下找 + push(`${prefix}/`); + return out; + } + + /** + * ⭐ 主入口:在给定端点集合上依次探测,返回第一个"能列出日历"的端点。 + * 判据=PROPFIND 得 207/200。真正的"日历集合筛选"仍由 DavClient.listCalendars 完成 + * (本方法只负责找到"哪个地址对",避免与既有解析逻辑重复)。 + * + * @param rawUrl 用户填入的原始地址(未规范化也可) + * @param username 用户名 + * @param auth Authorization 头 + * @param verify 可选的校验函数:传入端点,返回是否真的能列出日历(由调用方注入 listCalendars) + */ + static async resolve(rawUrl: string, username: string, auth: string, + verify?: (url: string) => Promise): Promise { + const res = new DiscoverResult(); + const base: string = DavDiscovery.tidy(rawUrl); + + // ① 网页分享链接:直接拦下,不浪费请求 + const kind: string = DavDiscovery.classify(base); + if (kind === 'share_calendar' || kind === 'share_file') { + res.ok = false; + res.hint = DavDiscovery.shareHint(kind); + LogUtil.write(`发现:识别为网页分享链接,已拦截(${kind})`); + return res; + } + + const check = async (url: string, via: string): Promise => { + const code: number = await DavDiscovery.probe(url, auth); + if (code !== 207 && code !== 200) { + return false; + } + if (verify !== undefined) { + const good: boolean = await verify(url); + if (!good) { + return false; + } + } + res.ok = true; + res.url = url; + res.via = via; + res.readOnly = (kind === 'public_caldav'); + LogUtil.write(`发现成功(${via}):${url}`); + return true; + }; + + // ① 用户填的地址优先(保持既有行为) + if (await check(base, 'direct')) { + return res; + } + + // ①b ⭐ 公开日历(只读订阅):Nextcloud 的 public-calendars 链接本身即端点。 + // 此类端点通常无需认证,且只能读——后续由 SyncEngine 的写权限探测自动标记为只读。 + if (kind === 'public_caldav') { + // 若用户填的是 public-calendars 根(少了 token),无法继续推断,直接给提示 + res.ok = false; + res.hint = '这是「公开日历」链接但似乎不完整。请使用完整的公开日历地址' + + '(形如 https://你的域名/remote.php/dav/public-calendars/<令牌>/),' + + '或改用日历的「私有链接」以获得可读可写体验。'; + return res; + } + + // ② .well-known/caldav(RFC 6764)——标准自动发现 + const origin: string = DavDiscovery.originOf(base); + if (origin !== '') { + const wk: string = await DavDiscovery.resolveWellKnown(origin, auth); + if (wk !== '') { + if (await check(wk, 'well-known')) { + return res; + } + // .well-known 给了地址但直接探测不通过 → 也纳入候选由 ③ 继续尝试其下级 + LogUtil.write(`.well-known 指向 ${wk},但该地址未通过日历校验,继续尝试常见路径`); + } + } + + // ③ 常见候选路径 + const cands: string[] = DavDiscovery.candidates(base, username); + for (const c of cands) { + if (await check(c, 'candidate')) { + return res; + } + } + + // 全部失败 → 给可操作提示(区分"不是 DAV 端点"与"能连但列不出日历") + res.ok = false; + const cap: DavCapability = await DavDiscovery.optionsCapability(base, auth); + if (cap.status === 405 || (!cap.isDav && cap.allow !== '' && !cap.supportsPropfind)) { + res.hint = '该地址不是 CalDAV 端点(服务器不接受 WebDAV 方法,返回 405)。' + + '常见原因是填了网页分享链接、或只填了站点的 DAV 根。' + + 'Nextcloud 请填「日历 → 设置 → 复制私有链接」得到的地址,例如:' + + '`https://你的域名/remote.php/dav/calendars/你的用户名/`'; + } else if (cap.status === 401) { + res.hint = '服务器拒绝凭据(401),请检查用户名与密码。' + + 'Nextcloud 若开启了二次验证,需要使用「应用密码」。'; + } else if (cap.status === 301 || cap.status === 302) { + res.hint = '服务器发生了跳转,通常说明 `.well-known/caldav` 未正确配置' + + '(常见于反向代理或子目录部署)。可请 NAS 管理员把 `.well-known/caldav` ' + + '指向 `/remote.php/dav`,或直接填写完整的「日历家目录」地址。'; + } else { + res.hint = '已连接上服务器,但在该地址下未发现任何日历本。' + + '请确认填的是「日历家目录」而不是 DAV 根,例如:' + + '`https://你的域名/remote.php/dav/calendars/你的用户名/`'; + } + return res; + } + + /** + * 解析 .well-known/caldav: + * - 直接返回 207/200 → 该 URL 本身即端点 + * - 返回 301/302/307/308 → 读 Location 头作为端点 + * - 返回 401 时也读 Location(Nextcloud 常在认证后跳转) + */ + static async resolveWellKnown(origin: string, auth: string): Promise { + const url: string = `${origin}/.well-known/caldav`; + const httpRequest = http.createHttp(); + try { + const resp: http.HttpResponse = await httpRequest.request(url, { + method: 'PROPFIND' as http.RequestMethod, + header: { + 'Authorization': auth, + 'Content-Type': 'application/xml; charset=utf-8', + 'Depth': '0', + 'User-Agent': 'SyncCalendar/1.0' + }, + extraData: '' + + '', + connectTimeout: 8000, + readTimeout: 12000 + }); + LogUtil.write(`HTTP PROPFIND(.well-known) ${url} → ${resp.responseCode}`); + if (resp.responseCode === 207 || resp.responseCode === 200) { + return url; + } + if (resp.responseCode === 301 || resp.responseCode === 302 + || resp.responseCode === 307 || resp.responseCode === 308 || resp.responseCode === 401) { + const headers = resp.header as Record; + if (headers !== undefined && headers !== null) { + const loc = headers['Location'] ?? headers['location'] ?? ''; + if (typeof loc === 'string' && loc !== '') { + const full: string = loc.startsWith('http') ? loc : origin + (loc.startsWith('/') ? '' : '/') + loc; + LogUtil.write(`.well-known 跳转到:${full}`); + return DavDiscovery.tidy(full); + } + } + } + return ''; + } catch (err) { + const e = err as BusinessError; + LogUtil.write(`.well-known 探测异常: ${e.code} - ${e.message}`); + return ''; + } finally { + httpRequest.destroy(); + } + } + + /** 状态码是否表示"该地址不是 DAV 端点" */ + static isNotDav(code: number): boolean { + return NOT_DAV_CODES.indexOf(code) >= 0; + } + + // ───────────────────────── 给用户的"人话提示"(两个页面共用) ───────────────────────── + + /** + * ⭐ 把 HTTP 状态码翻译成"人话 + 可操作指引"。 + * 用户看到"服务器返回 405"无从下手,这里直接告诉他下一步做什么。 + */ + static statusHint(code: number): string { + if (code === 405) { + return '该地址不是 CalDAV 端点(服务器不接受 WebDAV 方法,返回 405)。\n\n' + + '常见原因:填了「分享链接」(网页地址),或只填到 DAV 根。\n' + + '请填「日历家目录」,例如:\n' + + 'https://你的域名/remote.php/dav/calendars/你的用户名/\n\n' + + 'Nextcloud 可在网页版「日历 → 左下角设置 → 目标日历 → 复制私有链接」拿到这个地址。'; + } + if (code === 401) { + return '服务器拒绝凭据(401):请检查用户名与密码。\n' + + '若服务器开启了二次验证(2FA),需要使用「应用密码」而不是登录密码。'; + } + if (code === 403) { + return '服务器拒绝访问(403):账号可能无权限,或 IP 被限制。请检查账号权限与服务器访问限制。'; + } + if (code === 404) { + return '地址不存在(404):请检查路径是否写错。\n' + + '正确形式通常为 https://你的域名/remote.php/dav/calendars/你的用户名/'; + } + if (code === 301 || code === 302 || code === 307 || code === 308) { + return '服务器发生跳转:通常说明 `.well-known/caldav` 未正确配置(常见于反向代理或子目录部署)。\n' + + '请直接填写完整的「日历家目录」地址,或请 NAS 管理员配置 `.well-known/caldav`。'; + } + if (code === 0) { + return '无法连接服务器:请确认地址、端口与网络(内网地址需在同一局域网)。'; + } + return `获取日历列表失败,服务器返回:${code}`; + } + + /** ⭐ 能连上但"未发现日历本"时的提示:区分"地址差一层"与"确实是空的" */ + static emptyHint(url: string, username: string): string { + if (DavDiscovery.looksIncomplete(url)) { + const cands: string[] = DavDiscovery.candidates(url, username); + const example: string = cands.length > 0 ? cands[0] : ''; + return '该地址下没有日历本 —— 多半是地址「少了一层」。\n\n' + + '你填的是服务器根路径,但 CalDAV 需要填到「日历家目录」。请试试:\n' + + (example !== '' ? example + '\n\n' : '\n') + + '(Nextcloud 可在网页版「日历 → 设置 → 复制私有链接」直接拿到)'; + } + return '该地址下未发现日历本。请确认:\n' + + '1) 服务器上确实已创建日历;\n' + + '2) 填的是「日历家目录」(形如 .../remote.php/dav/calendars/用户名/)而不是 DAV 根。'; + } +} + +/** OPTIONS 探测结果 */ +export class DavCapability { + status: number = 0; + davHeader: string = ''; // `DAV:` 响应头(含 "1", "2", "calendar-access" 等) + allow: string = ''; // `Allow` 头 + isDav: boolean = false; + supportsPropfind: boolean = false; + supportsReport: boolean = false; +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/AddAccountPage.ets b/entry/src/main/ets/pages/AddAccountPage.ets index 5df116f..ee5300b 100644 --- a/entry/src/main/ets/pages/AddAccountPage.ets +++ b/entry/src/main/ets/pages/AddAccountPage.ets @@ -6,6 +6,8 @@ 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'; +import { DavDiscovery, DavCapability } from '../common/DavDiscovery'; +import { LogUtil } from '../common/LogUtil'; @Entry @Component @@ -107,12 +109,30 @@ struct AddAccountPage { code = await this.sendOnce(serverUrl, http.RequestMethod.GET, authHeader); } if (code === 401) { - this.statusMsg = '服务器拒绝凭据(401),请检查用户名密码'; + this.statusMsg = '服务器拒绝凭据(401):请检查用户名与密码。若服务器开启了二次验证,请使用「应用密码」'; this.statusOk = false; return false; } + // ⭐ 0.0.7:405 是"这里不是 DAV 端点"的典型信号 —— 直接给出可操作指引, + // 而不是笼统地报一个状态码让用户去猜。 + if (code === 405) { + const cap: DavCapability = await DavDiscovery.optionsCapability(serverUrl, authHeader); + if (!cap.isDav) { + this.statusMsg = '这个地址不是 CalDAV 端点(服务器不接受 WebDAV 方法,返回 405)。\n\n' + + '请填写「日历家目录」而不是 DAV 根或网页链接,例如:\n' + + 'https://你的域名/remote.php/dav/calendars/你的用户名/\n\n' + + '(也可以直接填域名,下一步会自动尝试发现)'; + this.statusOk = false; + return false; + } + } if (code >= 200 && code < 500) { - this.statusMsg = '服务器连接成功'; + // ⭐ 即使可连,也顺手提示"地址看起来不完整",让用户提前知道下一步会自动补全 + if (DavDiscovery.looksIncomplete(serverUrl)) { + this.statusMsg = '服务器连接成功(该地址看起来是服务器根路径,下一步会自动尝试定位日历家目录)'; + } else { + this.statusMsg = '服务器连接成功'; + } this.statusOk = true; return true; } @@ -186,6 +206,17 @@ struct AddAccountPage { if (this.isLoading) { return; } + // ⭐ 0.0.7:先识别「网页分享链接」——这类地址对 PROPFIND 必定 405, + // 提前拦下并给出人话指引,避免用户白跑一趟只看到"服务器返回 405"。 + const kind: string = DavDiscovery.classify(targetUrl); + const shareMsg: string = DavDiscovery.shareHint(kind); + if (shareMsg !== '') { + this.statusMsg = shareMsg; + this.statusOk = false; + LogUtil.write(`添加账号:识别为网页分享链接(${kind}),已拦截`); + return; + } + this.isLoading = true; this.statusMsg = '正在连接服务器…'; this.statusOk = false; @@ -229,7 +260,7 @@ struct AddAccountPage { .width('100%') Column({ space: 16 }) { - this.formField('服务器地址', '例如:https://nas.example.com/caldav/', this.serverUrl, + this.formField('服务器地址', '例如:https://nas.example.com/remote.php/dav/calendars/用户名/(也可只填域名,下一步会自动识别)', this.serverUrl, (value: string) => { this.serverUrl = value; }, false) diff --git a/entry/src/main/ets/pages/CalendarListPage.ets b/entry/src/main/ets/pages/CalendarListPage.ets index ebba581..f49e1c2 100644 --- a/entry/src/main/ets/pages/CalendarListPage.ets +++ b/entry/src/main/ets/pages/CalendarListPage.ets @@ -5,6 +5,7 @@ import { buffer } from '@kit.ArkTS'; import { BusinessError } from '@kit.BasicServicesKit'; import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore'; import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient'; +import { DavDiscovery, DiscoverResult, DavCapability } from '../common/DavDiscovery'; import { LogUtil } from '../common/LogUtil'; /** @@ -98,17 +99,44 @@ struct CalendarListPage { this.statusOk = false; return; } + const auth: string = 'Basic ' + token; + // ⭐ 0.0.7:先做地址自动发现/智能补全 —— 用户常只填域名或 DAV 根, + // 而 Nextcloud 等要求填「日历家目录」。此处自动找对端点,避免"读不出日历"。 + try { + const resolved: DiscoverResult = await DavDiscovery.resolve( + this.serverUrl, + this.username, + auth, + async (u: string): Promise => { + const d: DavCalendarDiscovery = await DavClient.listCalendars(u, auth); + return d.entries !== undefined && d.entries.length > 0; + }); + if (resolved.ok && resolved.url !== '' && resolved.url !== this.serverUrl) { + LogUtil.write(`服务地址已自动修正:${this.serverUrl} → ${resolved.url}(来源 ${resolved.via})`); + this.serverUrl = resolved.url; + } else if (!resolved.ok && resolved.hint !== '') { + // 自动发现彻底失败 → 直接把"人话原因"给用户,不再只报一个状态码 + this.isLoading = false; + this.statusMsg = resolved.hint; + this.statusOk = false; + return; + } + } catch (err) { + // 发现阶段异常不阻塞:继续用原地址走既有流程(保持旧行为兜底) + LogUtil.write(`地址发现阶段异常,回退原地址:${(err as BusinessError).message}`); + } // PROPFIND 与解析统一走 DavClient.listCalendars(与「编辑账号」页共用同一实现,避免重复正则逻辑) try { - const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.serverUrl, 'Basic ' + token); + const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.serverUrl, auth); this.isLoading = false; if (disc.statusCode === 401) { - this.statusMsg = '登录已失效,请返回重新连接'; + this.statusMsg = '服务器拒绝凭据(401):请返回检查用户名与密码。若服务器开启了二次验证,请使用「应用密码」'; this.statusOk = false; return; } if (disc.statusCode !== 207 && disc.statusCode !== 200) { - this.statusMsg = `获取日历列表失败,服务器返回:${disc.statusCode}`; + // ⭐ 0.0.7:把生硬的状态码换成人话 + 可操作指引 + this.statusMsg = DavDiscovery.statusHint(disc.statusCode); this.statusOk = false; return; } @@ -118,7 +146,8 @@ struct CalendarListPage { LogUtil.write(`发现日历本:「${item.name}」${item.href} 颜色=${item.color === '' ? '(无)' : item.color}`); } if (list.length === 0) { - this.statusMsg = '该路径下未发现日历本(没有包含 calendar 资源类型的集合)'; + // ⭐ 0.0.7:区分"地址层级不对"与"确实没有日历",并给出正确填法示例 + this.statusMsg = DavDiscovery.emptyHint(this.serverUrl, this.username); this.statusOk = false; } else { this.calendarList = list; diff --git a/entry/src/main/ets/pages/EditAccountPage.ets b/entry/src/main/ets/pages/EditAccountPage.ets index 9d298e7..c219eea 100644 --- a/entry/src/main/ets/pages/EditAccountPage.ets +++ b/entry/src/main/ets/pages/EditAccountPage.ets @@ -10,6 +10,7 @@ import { common } from '@kit.AbilityKit'; import { buffer } from '@kit.ArkTS'; import { BusinessError } from '@kit.BasicServicesKit'; import { DavAccount, AccountStore } from '../common/AccountStore'; +import { DavDiscovery, DiscoverResult } from '../common/DavDiscovery'; import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient'; import { EventDb } from '../common/EventDb'; import { BackupTargetCheck, SystemCalendarMirror } from '../common/SystemCalendarMirror'; @@ -141,16 +142,39 @@ struct EditAccountPage { return; } // PROPFIND 与解析统一走 DavClient.listCalendars(与「添加账号」页共用同一实现) + const auth: string = 'Basic ' + token; + // ⭐ 0.0.7:地址自动发现/智能补全(老账号可能当初填的是 DAV 根或已失效的路径) try { - const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.acc.serverUrl, 'Basic ' + token); + const resolved: DiscoverResult = await DavDiscovery.resolve( + this.acc.serverUrl, + this.acc.username, + auth, + async (u: string): Promise => { + const d: DavCalendarDiscovery = await DavClient.listCalendars(u, auth); + return d.entries !== undefined && d.entries.length > 0; + }); + if (resolved.ok && resolved.url !== '' && resolved.url !== this.acc.serverUrl) { + LogUtil.write(`编辑账号:服务地址自动修正 ${this.acc.serverUrl} → ${resolved.url}(${resolved.via})`); + this.acc.serverUrl = resolved.url; + } else if (!resolved.ok && resolved.hint !== '') { + this.isLoading = false; + this.statusMsg = resolved.hint; + this.statusOk = false; + return; + } + } catch (err) { + LogUtil.write(`编辑账号:地址发现异常,回退原地址 ${(err as BusinessError).message}`); + } + try { + const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.acc.serverUrl, auth); this.isLoading = false; if (disc.statusCode === 401) { - this.statusMsg = '登录已失效,请检查账号密码'; + this.statusMsg = '服务器拒绝凭据(401):请检查用户名与密码。若服务器开启了二次验证,请使用「应用密码」'; this.statusOk = false; return; } if (disc.statusCode !== 207 && disc.statusCode !== 200) { - this.statusMsg = `获取日历列表失败,服务器返回:${disc.statusCode}`; + this.statusMsg = DavDiscovery.statusHint(disc.statusCode); this.statusOk = false; return; } @@ -158,7 +182,7 @@ struct EditAccountPage { new EditCalendarItem(e.href, e.displayName, e.color)); if (list.length === 0) { LogUtil.write('编辑账号:未发现任何日历本'); - this.statusMsg = '该路径下未发现日历本'; + this.statusMsg = DavDiscovery.emptyHint(this.acc.serverUrl, this.acc.username); this.statusOk = false; } else { // 已勾选的日历本按账号当前配置预选