news 2026/9/22 15:21:23

Readest WebDAV 浏览面板排序与搜索功能实现解析:从 PROPFIND 客户端到纯函数工具库的完整设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Readest WebDAV 浏览面板排序与搜索功能实现解析:从 PROPFIND 客户端到纯函数工具库的完整设计

Readest WebDAV 浏览面板排序与搜索功能实现解析:从 PROPFIND 客户端到纯函数工具库的完整设计

【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest

导读

本文基于 Readest 开源仓库中关于 WebDAV 浏览面板(Settings > Integrations > WebDAV)排序与搜索功能的实现记录(issue #4724,合并 PR #4786),从数据模型、WebDAV 客户端、纯函数工具库、React 组件与持久化几个层面,完整剖析该功能的设计决策与源码落地。读完本文,你将掌握:如何在一个"整目录一次性拉取、纯客户端排序过滤"的架构中设计稳定的排序语义(目录置顶、缺省字段沉底)、如何让哈希命名的书籍目录按本地书库标题排序与检索,以及 WebDAV 服务器对<creationdate>支持不一致时如何优雅降级。


一、功能背景与整体布局

该功能为 Readest 的 WebDAV 集成(Settings > Integrations > WebDAV)中的远程文件浏览面板(browse pane)引入了排序(sort)与搜索(search / filter)能力,对应 issue #4724,最终在 PR #4786 合并(merge commit13e0fb814)。需要注意的是,该 PR 在开发后期被 rebase 到 #4784 的 provider 重构之上,这次重构将 WebDAV 客户端从services/webdav/WebDAVClient.ts移动到了新的目录,因此下文列出的路径均为合并后(post-#4784)的布局。

实现该功能的代码分散在四个位置,职责清晰:

文件(仓库根目录相对路径)职责
WebDAV 客户端PROPFIND 请求体新增<D:creationdate/>WebDAVEntry新增可选字段created
排序/过滤工具库纯函数sortWebDAVEntriesfilterWebDAVEntries,可独立单元测试
设置类型定义WebDAVBrowseSortByType类型与持久化的browseSortBy/browseSortAscending字段
浏览面板组件过滤输入框 + 排序下拉框 + 升/降序切换按钮,仅在普通浏览模式显示

架构上的一个关键取舍是:整个目录通过一次PROPFIND Depth: 1请求全部拉取(无分页),因此排序与过滤完全在客户端进行。这保证了两个工具函数是纯函数、易于测试,也让用户操作零网络延迟。


二、数据模型:WebDAVEntry与新增的created字段

排序功能需要"创建时间"这一维度,因此 WebDAV 客户端的目录条目模型被扩展。在 client.ts 中:

export interface WebDAVEntry { /** File or directory name (single path segment, decoded). */ name: string; /** Absolute path on the server, including leading slash, decoded. */ path: string; /** True when the entry is a collection (directory). */ isDirectory: boolean; /** Content length in bytes when reported by the server (files only). */ size?: number; /** Server-provided modification timestamp, if any. */ lastModified?: string; /** * Server-provided creation timestamp (`<creationdate>`), if any. Many * servers (NextCloud, sabre/dav, Synology) report it; some omit it, so * callers must treat `undefined` as "unknown" and not assume it equals * {@link lastModified}. */ created?: string; }

注意created是可选的:注释中明确列出了会返回<creationdate>的服务器(NextCloud、sabre/dav、Synology),同时也提醒调用方必须把undefined视为"未知",不能假设它等于lastModified

对应地,PROPFIND 请求体在 client.ts 中新增了一行:

<D:propfind xmlns:D="DAV:"> <D:prop> <D:displayname/> <D:resourcetype/> <D:getcontentlength/> <D:getlastmodified/> <D:creationdate/> </D:prop> </D:propfind>

解析响应时,listDirectory通过extractTagText(block, 'creationdate')读取该字段并填充进条目(client.ts)。解析器刻意不引入完整 XML 库,而是用一个容忍命名空间前缀差异的正则做 local-name 匹配,因为 WebDAV 服务器对前缀(d:D:、无前缀)的处理并不一致。


三、排序与过滤:纯函数工具库的设计

排序与过滤被抽离到独立的纯函数模块 webdavBrowseUtils.ts,组件文件只负责 React 状态与 JSX,工具函数可被 单元测试 独立验证。

3.1 排序语义:目录置顶、缺省字段沉底、稳定 tiebreak

sortWebDAVEntries(entries, sortBy, ascending, getName?)的核心排序逻辑如下:

export const sortWebDAVEntries = ( entries: WebDAVEntry[], sortBy: WebDAVBrowseSortByType, ascending: boolean, getName: WebDAVEntryNameResolver = (e) => e.name, ): WebDAVEntry[] => { const byNameAsc = (a: WebDAVEntry, b: WebDAVEntry): number => resolveName(a, getName).localeCompare(resolveName(b, getName), undefined, { sensitivity: 'base', }); const compareField = (a: WebDAVEntry, b: WebDAVEntry): number => { switch (sortBy) { case 'modified': return compareNullableNumber( parseTimestamp(a.lastModified), parseTimestamp(b.lastModified), ascending); case 'created': return compareNullableNumber( parseTimestamp(a.created), parseTimestamp(b.created), ascending); case 'size': return compareNullableNumber(a.size ?? null, b.size ?? null, ascending); case 'name': default: return ascending ? byNameAsc(a, b) : -byNameAsc(a, b); } }; return [...entries].sort((a, b) => { if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; const primary = compareField(a, b); return primary !== 0 ? primary : byNameAsc(a, b); }); };

设计决策值得逐条展开:

  • 目录始终置顶:第一层比较a.isDirectory !== b.isDirectory,目录永远排在文件前面——这是文件浏览器的常规行为,且不受排序字段与方向影响。测试keeps directories grouped before files regardless of field/direction验证了即使按 size 降序,目录仍排在前面(测试文件)。
  • 缺省字段沉底(双向)compareNullableNumber中,null(无日期/无大小)的条目永远排在已知值之后,无论升序还是降序——这样切换方向不会把"未知"顶到最上面:
const compareNullableNumber = (a: number | null, b: number | null, ascending: boolean): number => { if (a === null && b === null) return 0; if (a === null) return 1; if (b === null) return -1; const diff = a - b; return ascending ? diff : -diff; };

对应测试entries missing the sort field sort last in both directions, tie-broken by name断言了升序/降序下无日期条目都保持在最后(测试文件)。

  • 稳定 tiebreak:主排序键相等时回退到显示名称的升序比较(sensitivity: 'base'忽略大小写),保证顺序可预期。
  • 纯函数:通过[...entries].sort(...)返回新数组,绝不修改输入(测试does not mutate the input array验证,测试文件)。
  • 时间戳解析兼容两种格式parseTimestamp依赖Date.parse,同时兼容 RFC 1123(getlastmodified常见格式,如Mon, 01 Jan 2024 00:00:00 GMT)与 ISO 8601(creationdate常见格式,如2024-05-01T00:00:00Z),无法解析时返回null从而走"沉底"路径,而不是显示Invalid Date

3.2 过滤:大小写不敏感的子串匹配

filterWebDAVEntries(entries, query, getName?)对查询做trim().toLowerCase()后,同时匹配原始条目名与解析后的显示名(这样哈希目录也能按书库标题命中);空查询或纯空白查询原样返回输入:

export const filterWebDAVEntries = ( entries: WebDAVEntry[], query: string, getName: WebDAVEntryNameResolver = (e) => e.name, ): WebDAVEntry[] => { const q = query.trim().toLowerCase(); if (!q) return entries; return entries.filter((entry) => { if (entry.name.toLowerCase().includes(q)) return true; return resolveName(entry, getName).toLowerCase().includes(q); }); };

测试覆盖了大小写不敏感子串匹配(GREAT命中The Great Gatsby.epub)、哈希目录按显示标题匹配(punishment命中映射为Crime and Punishmenthash123)以及无匹配返回空数组(测试文件)。

3.3getName解析器:哈希目录 → 书库标题

这是本功能最巧妙的设计点。WebDAV 同步布局中,每本书的远程目录以内容哈希命名(在Readest/books/<hash>/下)。如果排序和过滤直接作用于entry.name,用户看到的将是毫无意义的哈希串。因此面板传入了一个解析器:

const resolveDisplayName = (entry: WebDAVEntry): string => { if (entry.isDirectory && currentPath === booksDirPath) { const matched = bookByHash.get(entry.name); if (matched) return matched.title || entry.name; } return entry.name; };

它只在"当前路径恰好是书籍目录Readest/books/"且"该哈希能在本地书库中匹配到Book"时才替换为book.title,其余情况回退为原始条目名(WebDAVBrowsePane.tsx)。这样:

  • "按名称排序"和过滤都作用于用户实际看到的标题,而不是哈希;
  • 标题装饰只在Readest/books内生效,避免无关目录下的哈希文件夹被误当成书名;
  • 测试name sort follows the resolved display name when providedmatches the resolved display title for hashed book directories分别验证了排序与过滤两条路径(测试文件)。

bookByHash索引直接订阅useLibraryStore的 library 状态(WebDAVBrowsePane.tsx),而非一次性读取 getState,因此其他地方(导入、下载)对书库的变更无需手动刷新即可反映到面板。


四、UI 与状态管理:普通模式的控件行

控件行位于 WebDAVBrowsePane.tsx,只在普通浏览模式渲染!cleanupMode),清理模式(cleanup mode)保留自己聚焦的工具栏。

控件行由三部分组成:

  1. 过滤输入框:搜索图标 + text input,placeholderFilter,非空时显示清除按钮;onKeyDown={(e) => e.stopPropagation()}防止在 Settings 对话框内输入时按键冒泡触发全局快捷键。
  2. 排序下拉框:四个选项Name/Date modified/Date created/Size
  3. 升/降序切换按钮MdArrowUpward/MdArrowDownward图标,title/aria-label 为Sort ascending/Sort descending

排序与过滤状态在组件中初始化并同步持久化(WebDAVBrowsePane.tsx):

const [query, setQuery] = useState(''); const [sortBy, setSortBy] = useState<WebDAVBrowseSortByType>(settings.browseSortBy ?? 'name'); const [ascending, setAscending] = useState<boolean>(settings.browseSortAscending ?? true);

控件行以entries.length > 0(过滤前的原始数量)为渲染门槛,因此即使查询无匹配导致列表为空,控件行依然保留、输入框仍可清空;空目录 / 加载失败时不显示控件行,保持界面整洁。

4.1 排序偏好持久化

排序字段与方向通过新的可选 proponUpdateSettings写回设置:

const handleSortByChange = (e: React.ChangeEvent<HTMLSelectElement>) => { const next = e.target.value as WebDAVBrowseSortByType; setSortBy(next); void onUpdateSettings?.({ browseSortBy: next }); }; const handleToggleDirection = () => { const next = !ascending; setAscending(next); void onUpdateSettings?.({ browseSortAscending: next }); };

在 WebDAVForm.tsx 中,onUpdateSettings被接到既有的persistWebdav

const persistWebdav = async (patch: Partial<typeof stored>) => { // ...保存部分设置补丁 }; <WebDAVBrowsePane settings={stored} onUpdateSettings={persistWebdav} />

onUpdateSettings被设计为可选:即使调用方未传入,面板依然能渲染,只是排序/搜索停留在会话级(不跨会话保留),这保证了组件的可复用性。

4.2 类型定义与无迁移的默认值

在 settings.ts 中:

export type WebDAVBrowseSortByType = 'name' | 'modified' | 'created' | 'size'; export interface WebDAVSettings { // ... browseSortBy?: WebDAVBrowseSortByType; browseSortAscending?: boolean; // ... }

两个新字段都是可选的:缺省时按name+ 升序,恰好复现功能上线前的旧行为(目录优先 + 字母序),因此不需要任何数据迁移。类型注释还点明了各字段的适用场景:"按日期拉出最近书籍"是主要动机,而created依赖服务器上报<creationdate>(并非所有服务器都支持)。

4.3 行内日期展示:让排序顺序"看得懂"

为了让当前排序立即可读,每行展示的日期跟随活动排序键(WebDAVBrowsePane.tsx):

const activeDateRaw = sortBy === 'created' ? entry.created : entry.lastModified;

即:按"Date created"排序时行内显示创建时间,其他排序保持显示修改时间;服务器未上报该字段时该行直接省略日期(渲染条件activeDateRaw &&),不会出现空字段。日期格式化由formatLastModified完成——先Date.parse,失败返回空串;toLocaleString的 options 参数在部分旧 Android WebView 上会抛异常,因此有 try/catch 回退到默认格式化器(webdavBrowseUtils.ts)。


五、搜索是瞬态的,只有排序偏好持久化

这是一个刻意为之的产品决策:搜索(过滤)在每次目录导航 / 刷新时重置,只有排序偏好跨会话保留

在加载 effect 中(WebDAVBrowsePane.tsx),每次路径 / 凭据 / reloadTick 变化都会:

setDownloadStatus({}); setQuery('');

并附带一个cancelled标志防止过期的 PROPFIND 响应覆盖当前目录(用户可能比一次网络往返导航得更快)。这样设计的原因:过滤是"针对当前目录"的临时视图,一个目录里输入的查询词不应静默隐藏下一个目录的行;而排序是用户的全局浏览习惯,理应记住。


六、实现要点与踩坑记录(Gotchas)

6.1 连续两次重构叠加

该功能落地期间正好叠了两个重构:

  • #4774移除了SyncHistoryPanel/syncLog
  • #4784(provider 无关的 FileSyncEngine)把WebDAVClient.ts移动为 services/sync/providers/webdav/client.ts,同时将面板切换为createWebDAVProvider+deleteRemoteBookDir(provider, hash)+FileSyncError+SYNC_BOOKS_DIR的调用方式。

rebase 通过 git 的 rename detection 自动把creationdate改动带进了新位置的客户端文件;实际冲突只出现在面板的 import 块与 locale 尾部。给维护者的启示:这类跨重构合并后,编辑任何文件前都应先重读重构后的代码,而不是依赖旧路径的记忆。

6.2<creationdate>并非所有服务器都返回

记录中有一个非常具体的事实:开发时的家庭测试服务器(192.168.2.3:6065)对全部条目返回getlastmodified但不返回creationdate(PROPFIND 实测 0/675),因此"Date created"在该服务器上会优雅降级为无日期的稳定名称序(即缺省字段沉底 + 名称 tiebreak 的兜底逻辑)。该行为在真机小米设备上通过pnpm dev-android+ adb/CDP 验证过。

这一条强化了上文的设计:created是可选字段、排序对缺失值沉底、行内日期按需省略——三层防御让"服务器不支持创建时间"不至于破坏浏览体验。

6.3 i18n:32 个 locale,藏语回退英文

该功能引入了 6 个新翻译键,共翻译了 32 个 locale;bo(藏语)保留英文回退。另有一个重要教训:i18n scanner 顺带发现了约 20 个来自其他功能的、预先存在且未翻译的键/locale,这些被刻意还原,没有并入本 PR——保持 PR 范围聚焦是刻意决定。


七、配套能力:下载、清理模式与相关改动

虽然排序/搜索是本次功能核心,浏览面板还包含若干紧密相关的既有能力,了解它们有助于理解面板的完整交互上下文:

  • 下载到书库handleDownloadEntry对支持的书格式显示下载按钮。它先通过isSupportedBookExt(复用 constants.ts 的SUPPORTED_BOOK_EXTS,与拖拽导入、文件夹导入共用同一份白名单,避免"能下载但无法入库")过滤,再走tauriDownload流式下载(规避 Android WebView 的 IPC 大小限制),随后ingestFile做哈希去重与 Book 记录创建(WebDAVBrowsePane.tsx)。
  • 清理模式(cleanup mode):用于远端孤儿目录(本地 Book 已软删除deletedAt、但服务器上仍存在)的批量 GC。进入时面板钉在Readest/books/,过滤出孤儿行,行内复选框多选,底部提供Delete from server批量删除。排序/搜索控件在清理模式下隐藏。删除走deleteRemoteBookDir(provider, hash),逐项成功即从列表 splice 掉(列表本身就是进度指示器),不再做冗余的批量后 PROPFIND;AUTH_FAILED会短路整个循环(WebDAVBrowsePane.tsx)。

浏览器上下文相关的路径常量(SYNC_BOOKS_DIR等)来自 services/sync/file/layout.ts,provider 工厂在 services/sync/providers/webdav/WebDAVProvider.ts,deleteRemoteBookDir在 services/sync/file/engine.ts。


八、测试与验证

排序/过滤的正确性由专门的单元测试保障:webdav-browse-sort-filter.test.ts。测试矩阵覆盖:

  • 目录在任意字段/方向上始终置顶;
  • 名称升/降序(大小写不敏感的比较语义);
  • 按修改时间、创建时间(ISO 8601)、大小排序;
  • 缺省字段(无日期/无大小)双向沉底 + 名称 tiebreak;
  • 传入getName时按解析后的显示名排序;
  • 输入数组不被修改;
  • 过滤:空查询全量返回、大小写不敏感子串匹配、哈希目录按显示标题匹配、无匹配返回空数组。

客户端的其他行为(连接检查、路径编码、列表解析、请求超时、写重试、流错误)也有对应的独立测试文件,例如 webdav-list-directory.test.ts、webdav-encode-path.test.ts、webdav-connect-settings.test.ts 等,共同构成该功能的回归防线。


结语

Readest WebDAV 浏览面板的排序与搜索功能,是一个"客户端纯函数 + 服务端单次拉取"架构的教科书式实现:PROPFIND Depth: 1整目录拉取让排序过滤零往返、纯函数工具库让核心逻辑可单测、getName解析器让哈希目录以人类可读标题参与排序检索、可选的持久化字段让旧用户无需迁移。而面对服务器对<creationdate>支持不一的事实,从数据模型(可选字段)、排序语义(缺失沉底)到 UI(行内日期按需省略)的三层防御,展示了真实产品在协议碎片化面前的工程韧性。

对维护者而言,合并记录中留下的两个提醒同样值得记住:跨大型重构 rebase 后先重读目标文件再编辑;以及把无关的存量 i18n 问题从特性 PR 中剥离,保持变更可审查、可回滚。

【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/21 14:31:02

Python学生信息管理系统开发与优化实践

1. 项目概述这个学生信息管理系统是一个典型的Python控制台应用程序&#xff0c;采用面向对象编程思想实现。系统通过三个模块文件协同工作&#xff0c;实现了学生信息的增删改查&#xff08;CRUD&#xff09;功能以及数据持久化存储。作为一个入门级的项目&#xff0c;它很好地…

作者头像 李华
网站建设 2026/9/21 14:22:03

Function Calling 智能诊断插件生态第三周成效与准确率实测

Function Calling 智能诊断插件生态第三周成效与准确率实测在第三周的故障诊断 Agent 专项攻坚战中&#xff0c;我们推动智能排障中枢完成了从“理论因果推演”向**“基于 Function Calling 只读探针生态 多 Agent 协同作战 AST 安全沙箱 智能工单秒级派发”**的工业级工程化…

作者头像 李华
网站建设 2026/9/21 14:21:38

2026年前端AI编程工具选型指南:咬合流水线而非语法补全

1. 为什么2026年前端开发者不能再凭直觉选AI编程工具我去年带三个实习生做电商中台项目&#xff0c;其中两个用Copilot&#xff0c;一个用Cursor。上线前一周压测时&#xff0c;Copilot生成的React状态管理逻辑在高并发下出现竞态条件——不是代码语法错&#xff0c;而是它默认…

作者头像 李华
网站建设 2026/9/21 14:19:08

ASP Response对象核心功能与优化实践

1. ASP Response对象基础解析作为一名有十年ASP开发经验的老兵&#xff0c;我经常遇到新手对Response对象理解不透彻的问题。Response对象在ASP中扮演着输出管道的角色&#xff0c;它就像是一个负责与客户端通信的邮差&#xff0c;把服务器处理好的数据准确无误地送达浏览器端。…

作者头像 李华