Skip to content

feat: add Quick Cat plugin - #26

Open
Wecury wants to merge 14 commits into
inpageedit:masterfrom
Wecury:feat/quick-cat
Open

feat: add Quick Cat plugin#26
Wecury wants to merge 14 commits into
inpageedit:masterfrom
Wecury:feat/quick-cat

Conversation

@Wecury

@Wecury Wecury commented Aug 1, 2026

Copy link
Copy Markdown

在工具箱中添加一个快速编辑分类的按钮,在弹窗中编辑页面的分类:
添加 / 删除 / 重命名、修改排序键、编辑默认排序键,添加时带分类自动补全,支持拖动排序。

灵感来源可视化编辑器的分类编辑工具,插件命名参考HotCat()但热猫处理多个分类不够爽所以有了快猫

toolbox

界面:
window

分类补全中的重定向样式:
redirect

配置首选项:
setting

暂时只内置了简体中文和英语,然后部分复用了IPE官方字典(

由 Sourcery 提供的摘要

新增 Quick Cat 插件,在 InPageEdit 中提供用于管理页面分类的模态界面,包括自动补全、拖拽排序和编辑选项。

新功能:

  • 引入 Quick Cat InPageEdit 插件,通过工具箱按钮和模态对话框来编辑页面分类。
  • 支持添加、删除、重命名和重新排序分类,包括编辑分类的排序键以及默认排序键,并带有冲突处理。
  • 提供具有重定向识别能力的分类名自动补全,以及简体中文和英文的本地化消息。
  • 暴露插件偏好设置,用于配置默认编辑摘要、标记为小编辑,以及是否允许点击空白处关闭对话框。

增强:

  • 实现对分类和 DEFAULTSORT 的健壮 wikitext 解析与重建,在保留周围内容的同时,谨慎处理重新排序和就地编辑。

构建:

  • 在工作区中注册新的 quick-cat 包,使用基于 Vite 的构建配置,并在 pnpm 工作区设置中启用 @parcel/watcher 构建。
Original summary in English

Summary by Sourcery

Add a new Quick Cat plugin that provides a modal UI for managing page categories within InPageEdit, including autocomplete, drag-and-drop reordering, and edit options.

New Features:

  • Introduce the Quick Cat InPageEdit plugin to edit page categories via a dedicated toolbox button and modal dialog.
  • Support adding, removing, renaming, and reordering categories, including editing sort keys and the default sort key with conflict handling.
  • Provide category name autocomplete with redirect awareness and localized messaging in Simplified Chinese and English.
  • Expose plugin preferences for default edit summary, minor-edit flag, and outside-click-to-close behavior.

Enhancements:

  • Implement robust wikitext parsing and reconstruction for categories and DEFAULTSORT that preserves surrounding content and handles reordering and in-place edits carefully.

Build:

  • Register the new quick-cat package in the workspace with Vite-based build configuration and enable @parcel/watcher builds in the pnpm workspace settings.

@sourcery-ai

sourcery-ai Bot commented Aug 1, 2026

Copy link
Copy Markdown

审阅者指南

新增一个 Quick Cat IPE 插件,提供用于编辑页面分类的基于模态框的界面(添加/删除/重命名、更改排序键、默认排序键、自动补全和拖动重新排序),将其集成到工具箱中并提供偏好设置和 i18n 支持,同时实现对分类和 DEFAULTSORT 处理的健壮 wikitext 解析与渲染。

Quick Cat 分类编辑流程的时序图

sequenceDiagram
  actor User
  participant Toolbox as ToolboxButton
  participant Plugin as QuickCatPlugin
  participant Modal as ModalService
  participant WikiPage as WikiPageService
  participant Page as IWikiPage
  participant Parse as parseCategories/buildWikitext

  User->>Toolbox: click button
  Toolbox->>Plugin: onClick(e)
  Plugin->>Plugin: showModal(qc)
  Plugin->>Modal: modal.createObject().init()
  Plugin->>WikiPage: wikiPage.newFromTitle(title)
  WikiPage-->>Plugin: page
  Plugin->>Parse: parseCategories(content, nsInfo)
  Plugin->>Plugin: renderDialog(qc, m, state)
  Plugin->>Modal: m.show()

  User->>Modal: click Save
  Modal->>Plugin: saveCategories(qc, m, state)
  Plugin->>Parse: buildWikitext(state.content, state.rows, state.defaultSort, state.categories, nsInfo)
  Plugin->>Page: page.edit({ text, summary, minor, baserevid })
  Page-->>Plugin: edit result
  Plugin->>Modal: modal.notify('success', { title: t('saved') })
  alt state.reloadAfterSave
    Plugin->>User: window.location.reload()
  end
Loading

文件级变更

变更 详情 文件
引入 Quick Cat 插件入口,用于注册工具箱按钮、模态框工作流、偏好设置以及编辑页面分类的保存逻辑。
  • 使用 defineIPEPlugin 定义插件,并通过 symbol 标志防止重复应用。
  • 接入工具箱按钮,使用标签图标,根据是否可编辑动态设置提示文本/禁用样式,并添加点击处理程序以打开 Quick Cat 模态框。
  • 实现模态框创建、当前页面内容加载、将分类解析为 CategoryState,并渲染主对话框(列表、工具栏、默认排序和选项区域)。
  • 处理保存流程:校验、重复检测、未更改短路、wikitext 重构、page.edit 提交及冲突处理、分析事件以及可选的保存后自动刷新。
  • 通过核心配置注册表注册自定义偏好设置,包括默认摘要、默认小编辑标记以及点击模态框外部关闭的行为。
packages/quick-cat/src/index.tsx
实现健壮的分类/DEFAULTSORT 解析以及 wikitext 重建工具,保留周围内容并区分重新排序与原位编辑。
  • 在 wikitext 中解析分类链接和 DEFAULTSORT/DEFAULTSORTKEY,同时屏蔽注释、类似 nowiki 的标签以及非 defaultsort 模板。
  • 计算本地化的分类命名空间别名/信息,并提供从标题中去除命名空间以及基于默认排序渲染链接的辅助函数。
  • 提供函数以移除现有 DEFAULTSORT 和分类链接、查找分类块、检测重排与简单变更,并以原位或追加到末尾的策略重建分类部分。
  • 支持类似 HotCat 的插入点以及基于偏移量的安全文本编辑应用,包括对被移除分类的按行删除。
  • 暴露 buildWikitext 和 isUnchanged,用于根据当前状态生成最终 wikitext 并检测是否为无操作编辑。
packages/quick-cat/src/parse.ts
为分类名添加自动补全/搜索功能,支持重定向感知和符合 ARIA 规范的下拉 UI。
  • 使用 opensearch 和 allpages API 查询实现 searchCategories,基于上下文进行前缀缓存(5 分钟),并通过后续 info/redirects 查询解析重定向。
  • 定义 attachAutocomplete,将输入框和建议容器与去抖动搜索连接起来,通过请求序列号防止竞态,并在 body 层面定位下拉框。
  • 渲染建议项,支持重定向样式/目标显示、键盘导航(上/下/回车/ESC)、点击外部关闭,以及 ARIA combobox/listbox 属性。
  • 暴露 onPick 和 onEnter 钩子,便于使用方在选择项时更新行状态,或在无建议时处理回车以切换焦点。
packages/quick-cat/src/autocomplete.tsx
为每个插件创建独立上下文,用于日志记录、i18n 和命名空间信息,避免全局单例并支持多实例安装。
  • 定义 QuickCatContext/QuickCatLogger 类型,以及封装核心 ctx 的 createQuickCatContext 工厂函数。
  • 通过 initCategoryNsInfo 初始化本地化的分类命名空间信息,并在上下文中暴露。
  • 使用 ctx.i18n.registerMessages 按插件命名空间注册 zh-hans/zh-hant/en 的 quick-cat 消息包,并定义回退的英文消息。
  • 实现 t(key, ...args) 帮助函数,优先通过 ctx.$$ 尝试官方或带命名空间的 key,当缺失时回退到内置消息表。
packages/quick-cat/src/context.ts
packages/quick-cat/src/types.ts
为 Quick Cat 对话框添加 UI 状态管理、DOM 辅助函数和样式,包括拖放列表行为和布局。
  • 引入 CategoryState 模型以及用于选择、删除和拖动重排操作的纯变更辅助函数。
  • 渲染分类行:复选框、拖拽手柄(基于指针的拖放)、带自动补全的名称/排序输入框以及移除按钮;实现列表工具栏,包括全选、已选数量、添加行和删除所选。
  • 实现默认排序区域以及信息提示图标,通过 API/mw.msg 获取并缓存帮助文本,并在更改时对继承排序键应用类似 VE 的行为。
  • 添加选项区域,用于摘要/小编辑/刷新标记、冲突错误通知,以及带自动滚动和放置指示器的基于指针的拖动处理程序。
  • 定义用于标签/信息/加号图标的静态 SVG DOM 辅助函数,以及用于布局、下拉框、焦点/悬停状态和模态集成的 SCSS 样式。
  • 为新插件配置 Vite、TS 和包元数据,包括构建输出、别名和 IPE loader 配置;在 pnpm-workspace 配置中允许 @parcel/watcher 构建。
packages/quick-cat/src/categoryState.ts
packages/quick-cat/src/dom.ts
packages/quick-cat/src/style.scss
packages/quick-cat/vite.config.ts
packages/quick-cat/tsconfig.json
packages/quick-cat/package.json
pnpm-workspace.yaml
pnpm-lock.yaml

提示和命令

与 Sourcery 交互

  • 触发新审阅: 在 Pull Request 中评论 @sourcery-ai review
  • 继续讨论: 直接回复 Sourcery 的审阅评论。
  • 从审阅评论生成 GitHub Issue: 回复 Sourcery 的某条审阅评论,要求它从该评论创建一个 issue。你也可以直接回复该审阅评论并写上 @sourcery-ai issue 来从中创建 issue。
  • 生成 Pull Request 标题: 在 Pull Request 标题的任意位置写上 @sourcery-ai,即可随时生成标题。你也可以在 Pull Request 中评论 @sourcery-ai title 来(重新)生成标题。
  • 生成 Pull Request 摘要: 在 Pull Request 正文任意位置写上 @sourcery-ai summary,即可在你希望的位置生成 PR 摘要。你也可以在 Pull Request 中评论 @sourcery-ai summary 来在任意时间(重新)生成摘要。
  • 生成审阅者指南: 在 Pull Request 中评论 @sourcery-ai guide,即可在任意时间(重新)生成审阅者指南。
  • 解决所有 Sourcery 评论: 在 Pull Request 中评论 @sourcery-ai resolve 来解决所有 Sourcery 评论。如果你已经处理完所有评论且不想再看到它们,这会很有用。
  • 撤销所有 Sourcery 审阅: 在 Pull Request 中评论 @sourcery-ai dismiss 来撤销所有现有的 Sourcery 审阅。如果你想从头开始一次新的审阅,这尤其有用 —— 别忘了再评论 @sourcery-ai review 以触发新的审阅!

自定义你的体验

打开你的 控制面板 来:

  • 启用或禁用审阅功能,例如 Sourcery 自动生成的 Pull Request 摘要、审阅者指南等。
  • 更改审阅语言。
  • 添加、移除或编辑自定义审阅指令。
  • 调整其他审阅设置。

获取帮助

Original review guide in English

Reviewer's Guide

Adds a new Quick Cat IPE plugin that provides a modal-based UI for editing page categories (add/remove/rename, change sort keys, default sort key, autocomplete and drag-to-reorder), integrates it into the toolbox with preferences and i18n, and implements robust wikitext parsing/rendering for categories and DEFAULTSORT handling.

Sequence diagram for Quick Cat category editing flow

sequenceDiagram
  actor User
  participant Toolbox as ToolboxButton
  participant Plugin as QuickCatPlugin
  participant Modal as ModalService
  participant WikiPage as WikiPageService
  participant Page as IWikiPage
  participant Parse as parseCategories/buildWikitext

  User->>Toolbox: click button
  Toolbox->>Plugin: onClick(e)
  Plugin->>Plugin: showModal(qc)
  Plugin->>Modal: modal.createObject().init()
  Plugin->>WikiPage: wikiPage.newFromTitle(title)
  WikiPage-->>Plugin: page
  Plugin->>Parse: parseCategories(content, nsInfo)
  Plugin->>Plugin: renderDialog(qc, m, state)
  Plugin->>Modal: m.show()

  User->>Modal: click Save
  Modal->>Plugin: saveCategories(qc, m, state)
  Plugin->>Parse: buildWikitext(state.content, state.rows, state.defaultSort, state.categories, nsInfo)
  Plugin->>Page: page.edit({ text, summary, minor, baserevid })
  Page-->>Plugin: edit result
  Plugin->>Modal: modal.notify('success', { title: t('saved') })
  alt state.reloadAfterSave
    Plugin->>User: window.location.reload()
  end
Loading

File-Level Changes

Change Details Files
Introduce Quick Cat plugin entry that registers toolbox button, modal workflow, preferences, and save logic for editing page categories.
  • Define plugin with defineIPEPlugin, preventing double-application via a symbol flag.
  • Wire a toolbox button with tag icon, dynamic tooltip/disabled styling based on editability, and click handler opening the Quick Cat modal.
  • Implement modal creation, loading of current page content, parsing categories into CategoryState, and rendering the main dialog with list, toolbar, default sort, and options sections.
  • Handle save flow: validation, duplicate detection, unchanged short-circuit, wikitext rebuild, page.edit submission with conflict handling, analytics events, and optional reload-after-save.
  • Register custom preferences for default summary, default minor edit flag, and click-outside-to-close behavior via the core config registry.
packages/quick-cat/src/index.tsx
Implement robust category/defaultsort parsing and wikitext rebuilding utilities that preserve surrounding content and handle reordering vs in-place edits.
  • Parse category links and DEFAULTSORT/DEFAULTSORTKEY from wikitext while masking comments, nowiki-like tags, and non-defaultsort templates.
  • Compute localized category namespace aliases/info and helpers for stripping namespace from titles and rendering links based on default sort.
  • Provide functions to strip existing DEFAULTSORT and category links, find category blocks, detect reordering vs simple changes, and rebuild category sections in-place or by append-at-end strategy.
  • Support HotCat-style insertion point and safe text-edit application via offset-based edits, including line-aware deletion for removed categories.
  • Expose buildWikitext and isUnchanged to generate final wikitext and detect no-op edits for the current state.
packages/quick-cat/src/parse.ts
Add autocomplete/search for category names with redirect awareness and ARIA-compliant dropdown UI.
  • Implement searchCategories using opensearch and allpages API queries, with 5-minute per-context prefix cache and redirect resolution via follow-up info/redirects queries.
  • Define attachAutocomplete to wire an input + suggestion container with debounced search, guarded by a request sequence, and body-level positioned dropdown.
  • Render suggestion items with redirect styling/target display, keyboard navigation (up/down/enter/escape), click-outside dismissal, and ARIA combobox/listbox attributes.
  • Expose hooks for onPick and onEnter so the consumer can update row state or shift focus on enter with no suggestions.
packages/quick-cat/src/autocomplete.tsx
Create per-plugin context for logging, i18n, and namespace info to avoid global singletons and support multiple installs.
  • Define QuickCatContext/QuickCatLogger types and a createQuickCatContext factory wrapping the core ctx.
  • Initialize localized category namespace info via initCategoryNsInfo and expose on context.
  • Register zh-hans/zh-hant/en quick-cat message bundles via ctx.i18n.registerMessages with plugin namespace, and define fallback English messages.
  • Implement t(key, ...args) helper that first tries ctx.$$ with official or namespaced keys, falling back to built-in message tables when missing.
packages/quick-cat/src/context.ts
packages/quick-cat/src/types.ts
Add UI state management, DOM helpers, and styles for the Quick Cat dialog, including drag-and-drop list behavior and layout.
  • Introduce CategoryState model and pure mutation helpers for selection, deletion, and drag-reorder operations.
  • Render category rows with checkbox, drag grip (pointer-based DnD), name/sort inputs with autocomplete, and remove button; implement list toolbar with select-all, selection count, add-row, and delete-selected.
  • Implement default sort section with info tooltip that fetches/ caches help text via API/mw.msg and applies VE-like behavior to inherited sort keys when changed.
  • Add options section for summary/minor edit/reload flags, conflict error notifier, and pointer-based drag handlers with auto-scroll and drop indicators.
  • Define static SVG DOM helpers for tag/info/plus icons and SCSS styling for layout, dropdown, focus/hover states, and modal integration.
  • Configure Vite, TS, and package metadata for the new plugin, including build output, aliases, and IPE loader configuration; allow @parcel/watcher builds in pnpm-workspace config.
packages/quick-cat/src/categoryState.ts
packages/quick-cat/src/dom.ts
packages/quick-cat/src/style.scss
packages/quick-cat/vite.config.ts
packages/quick-cat/tsconfig.json
packages/quick-cat/package.json
pnpm-workspace.yaml
pnpm-lock.yaml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - 我发现了 2 个安全问题、3 个其他问题,并给出了一些整体性的反馈:

安全问题

  • innerHTMLouterHTMLdocument.write 等方法中使用用户可控数据是一种反模式,可能导致 XSS 漏洞(链接
  • wrapper.innerHTML 中使用用户可控数据是一种反模式,可能导致 XSS 漏洞(链接

总体评论

  • 自动完成下拉框作为一个固定的 portal 渲染在 document.body 上,并使用了非常高的 z-index,这可能会与其他模态框/工具产生遮挡;建议把它限制在模态框容器内,或者使用更保守的层叠上下文,以避免与其他 UI 的布局冲突。
  • 多个注入的依赖被声明为 any 类型(例如 Ctx 中的 apimodaltoolbox),这会让代码更难理解;将这些类型收紧到来自 @inpageedit/core 的实际 MwApi/Modal/Toolbox 接口(或本地类型定义)会提升可维护性,并在编译阶段捕获集成错误。
给 AI Agent 的提示
请根据以下代码审查中的评论进行修改:

## 整体评论
- 自动完成下拉框作为一个固定的 portal 渲染在 `document.body` 上,并使用了非常高的 z-index,这可能会与其他模态框/工具产生遮挡;建议把它限制在模态框容器内,或者使用更保守的层叠上下文,以避免与其他 UI 的布局冲突。
- 多个注入的依赖被声明为 `any` 类型(例如 `Ctx` 中的 `api``modal``toolbox`),这会让代码更难理解;将这些类型收紧到来自 `@inpageedit/core` 的实际 MwApi/Modal/Toolbox 接口(或本地类型定义)会提升可维护性,并在编译阶段捕获集成错误。

## 具体评论

### 评论 1
<location path="packages/quick-cat/src/parse.ts" line_range="259" />
<code_context>
+export function buildAppend(original: string, rows: CategoryRow[], defaultSort: string): string {
+  let text = stripDefaultSort(original)
+  text = stripCategoryLinks(text)
+  text = text.replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/[ \t\r\n]+$/, '')
+
+  const defaultNs = getCategoryNamespaceName()
</code_context>
<issue_to_address>
**suggestion:** 对空白字符的规范化处理对某些页面来说可能过于激进。

`buildAppend``buildInPlace` 都会对整个 `original` 文本中 3 个以上的连续换行做折叠,并裁剪首尾的空白。这可能会改变页面布局,或者影响与分类无关的、刻意留出的空行/间距。如果需要保留与分类无关的空白,建议把规范化限制在分类/defaultsort 区域(链接被追加/修改的地方),或者使用更窄、更有针对性的规则。

建议的实现:

```typescript
export function buildAppend(original: string, rows: CategoryRow[], defaultSort: string): string {
  let text = stripDefaultSort(original)
  text = stripCategoryLinks(text)
  // 保留与分类无关的空白;只裁剪末尾的空格/制表符,以便在末尾干净地追加
  text = text.replace(/[ \t]+$/, '')

  const defaultNs = getCategoryNamespaceName()

```

`buildInPlace` 当前对整个 `original` 文本也应用了类似的激进空白规范化。为了完整实现建议并保留非分类空白,需要在该函数中做类似的调整:

1. 移除对整页进行的「3 个以上换行折叠」以及首尾空白裁剪。
2. 如有需要,用一个更窄的规则替代,只影响分类/defaultsort 插入点附近的末尾空格/制表符,或者将规范化限制在更新分类/defaultsort 的具体区域。
</issue_to_address>

### 评论 2
<location path="packages/quick-cat/src/index.ts" line_range="133" />
<code_context>
+  submissionError: 'Submission Error',
+}
+
+let currentCtx: Ctx | null = null
+let suggestSeq = 0
+let optSeq = 0
</code_context>
<issue_to_address>
**issue (complexity):** 建议把插件的全局状态、分类对话框的状态变更,以及自动完成逻辑拆分为小型、可复用的模块和按插件划分的上下文对象,在不改变行为的前提下,让代码更容易理解和维护。

你可以在保留当前所有行为的情况下,通过抽取一些通用部分、理清「状态 vs 视图」来降低复杂度。下面是一些不会改变功能的具体、渐进式重构建议。

### 1. 用每个插件自己的上下文替代可变的全局单例

`currentCtx``_logger``suggestSeq``optSeq` 都是全局的,并且和生命周期绑定。你可以用一个小的上下文对象把它们显式化,并在需要的地方传递。

```ts
// quickCatContext.ts
export interface QuickCatContext {
  ctx: Ctx
  logger: typeof log
  suggestSeq: number
  optSeq: number
}

export function createQuickCatContext(ctx: Ctx): QuickCatContext {
  return {
    ctx,
    logger: (ctx as any).logger?.('quick-cat')
      ? {
          info: (...a) => (ctx as any).logger('quick-cat').info(...a),
          warn: (...a) => (ctx as any).logger('quick-cat').warn(...a),
          error: (...a) => (ctx as any).logger('quick-cat').error(...a),
        }
      : log,
    suggestSeq: 0,
    optSeq: 0,
  }
}
```

然后在插件入口中:

```ts
apply(ctx: InPageEdit): void {
  const c = ctx as Ctx
  if ((c as any)[APPLIED_FLAG]) return
  ;(c as any)[APPLIED_FLAG] = true

  const qc = createQuickCatContext(c)

  // 使用 qc.logger 替代全局 _logger/log
  // 将 qc 传递给 attachAutocomplete、showModal 等。
}
```

`attachAutocomplete` 中:

```ts
function attachAutocomplete(
  qc: QuickCatContext,
  m: any,
  input: HTMLInputElement,
  suggest: HTMLElement,
  handlers: AutocompleteHandlers = {},
): void {
  // 使用 qc.ctx 替代 ctx,使用 qc.suggestSeq / qc.optSeq 替代全局变量
  suggest.id = suggest.id || `ipe-quick-cat__suggest-${++qc.suggestSeq}`
  // ...
}
```

这样可以保持行为不变,同时移除隐藏的跨插件状态,并让生命周期更易于理解。

---

### 2. 在对话框中将状态变更与 DOM 更新拆分

`renderDialog` 当前把 `CategoryState` 的变更和 DOM 更新混在一起(尤其是拖拽排序和选择逻辑)。把状态变更封装到几个小型纯函数中,可以让事件处理器更易读、更易测试。

你可以把选择和拖拽重排等逻辑挪到一个小的 `categoryState.ts` 中:

```ts
// categoryState.ts
export function toggleSelection(state: CategoryState, row: CategoryRow, checked: boolean) {
  if (checked) state.selected.add(row)
  else state.selected.delete(row)
}

export function selectAll(state: CategoryState, on: boolean) {
  if (on) state.rows.forEach((r) => state.selected.add(r))
  else state.selected.clear()
}

export function deleteSelected(state: CategoryState) {
  state.rows = state.rows.filter((r) => !state.selected.has(r))
  state.selected.clear()
}

export function startDrag(state: CategoryState, row: CategoryRow) {
  state._dragIndex = state.rows.indexOf(row)
}

export function reorderRow(state: CategoryState, toIndex: number) {
  if (state._dragIndex == null) return
  const from = state._dragIndex
  const [moved] = state.rows.splice(from, 1)
  const target = from < toIndex ? toIndex - 1 : toIndex
  state.rows.splice(target, 0, moved)
  state._dragIndex = null
}
```

然后在 `renderDialog` / `createCategoryRow` 中,事件处理器只需调用这些辅助函数,再执行 `refreshList` / `refreshToolbar`

```ts
check.addEventListener('change', () => {
  toggleSelection(state, row, check.checked)
  refreshToolbar()
})

grip.addEventListener('pointerdown', (e) => {
  if (e.pointerType === 'mouse' && e.button !== 0) return
  e.preventDefault()
  startDrag(state, row)
  grip.setPointerCapture(e.pointerId)
  rowEl.classList.add('is-dragging')
})

deleteBtn.addEventListener('click', () => {
  deleteSelected(state)
  refreshList()
  refreshToolbar()
})

list.addEventListener('pointerup', (e) => {
  if (state._dragIndex == null) return
  const to = computeInsertIndex(e.clientY)
  reorderRow(state, to)
  refreshList()
})
```

这样可以在保持所有 UI 行为完全一致的前提下,更清晰地区分哪些函数负责修改状态,哪些只负责更新 DOM。

---

### 3. 将自动完成逻辑抽取为可复用模块

`attachAutocomplete` 本身已经相当通用。把它和相关的搜索/缓存逻辑移动到 `autocomplete.ts` 中,可以让主模块更简洁,降低理解成本。

```ts
// autocomplete.ts
export interface AutocompleteHandlers {
  onPick?: (value: string) => void
  onEnter?: () => void
}

export function attachCategoryAutocomplete(
  qc: QuickCatContext,
  m: any,
  input: HTMLInputElement,
  suggest: HTMLElement,
  handlers: AutocompleteHandlers = {},
): void {
  // 当前 attachAutocomplete 的内容,但使用 qc.ctx / qc.suggestSeq / qc.optSeq
}
```

`createCategoryRow` 中的使用方式几乎保持不变:

```ts
const nameSuggest = h('div', { class: 'ipe-quick-cat__suggest' })

attachCategoryAutocomplete(qc, m, nameInput, nameSuggest, {
  onPick: (cat) => {
    row.name = cat
    nameInput.value = cat
  },
})
```

`attachAutocomplete``searchCategories` 做这样的抽取可以在不改变行为的情况下,从主文件中移除一大块代码,并让自动完成逻辑可以独立测试。

---

这些改动都是渐进式的(没有行为变化),主要针对几个复杂度来源:可变全局状态、混合状态/DOM 的逻辑,以及主插件文件中体量很大的通用自动完成实现。
</issue_to_address>

### 评论 3
<location path="packages/quick-cat/src/parse.ts" line_range="37" />
<code_context>
+const RAW_TAGS = ['nowiki', 'pre', 'code', 'math', 'syntaxhighlight', 'source', 'timeline', 'poem', 'hiero']
+let _lastMaskedSource: string | null = null
+let _lastMasked: string | null = null
+function maskIgnoredRegions(text: string): string {
+  // Single-entry memo: parse/build steps frequently mask the same text
+  if (text === _lastMaskedSource) return _lastMasked!
</code_context>
<issue_to_address>
**issue (complexity):** 建议重构解析工具方法以分离职责、消除隐藏的全局状态,并统一分类块的重建路径,在保持现有行为的前提下让代码更容易理解和维护。

当前的主要复杂度来自责任混合以及隐藏的全局状态。在保留所有现有行为的情况下,你可以对几个关键区域做简化:

---

### 1. 让 `maskIgnoredRegions` 变成纯函数,并将 DEFAULTSORT 处理拆分出来

现在的 `maskIgnoredRegions`

- 屏蔽注释/标签/模板
- 使用 `_lastMaskedSource` / `_lastMasked` 做单条缓存
- 内联了一套手写的逻辑来保留 DEFAULTSORT 模板

你可以:

1. 移除缓存(调用方已经传入文本;如果以后需要缓存,可以在外层做)。
2. 使用 `findDefaultSortMatches` 获取需要保留的范围,然后在更清晰的一次遍历中屏蔽其他模板。

示例重构大纲:

```ts
function maskIgnoredRegions(text: string): string {
  let masked = text.replace(/<!--[\s\S]*?-->/g, m => ' '.repeat(m.length));
  for (const tag of RAW_TAGS) {
    masked = masked.replace(
      new RegExp(`<${tag}(?:\\s[^>]*)?>[\\s\\S]*?<\\/${tag}>`, 'gi'),
      m => ' '.repeat(m.length)
    );
  }

  const dsRanges = findDefaultSortMatches(masked).map(m => [m.start, m.end + 2] as [number, number]);
  const chars = masked.split('');
  let depth = 0;

  for (let i = 0; i < chars.length - 1; i++) {
    if (masked.startsWith('{{', i)) {
      const inDefaultSort = dsRanges.some(([s, e]) => i >= s && i < e);
      if (!inDefaultSort) depth++;
      i++; // skip second '{'
    } else if (masked.startsWith('}}', i)) {
      if (depth > 0) depth--;
      i++; // skip second '}'
    } else if (depth > 0) {
      chars[i] = ' ';
    }
  }

  return chars.join('');
}
```

这样可以保留「保留 DEFAULTSORT,屏蔽其他模板」的行为,同时:

- 去掉全局缓存
- 将「查找 DEFAULTSORT」(已有 `findDefaultSortMatches`)与「屏蔽模板」分离,减少嵌套逻辑和特殊分支。

如果确实需要缓存,可以考虑由调用方管理的辅助类:

```ts
class Masker {
  private cache = new Map<string, string>();
  mask(text: string): string {
    if (this.cache.has(text)) return this.cache.get(text)!;
    const masked = maskIgnoredRegions(text);
    this.cache.set(text, masked);
    return masked;
  }
}
```

---

### 2. 用显式上下文替代全局 `_catNsAlt`

`_catNsAlt` 是多个辅助函数使用的可变全局状态,这会让行为依赖于隐式的初始化顺序。

你可以保留缓存,但把它做成显式且非全局的:

```ts
interface CategoryNsInfo {
  alt: string;
  name: string;
}

function initCategoryNsInfo(): CategoryNsInfo {
  // 将当前 getCategoryNamespaceAlt / getCategoryNamespaceName 的逻辑合并
  const alt = computeCategoryNamespaceAlt();   // 与现有 getCategoryNamespaceAlt 相同,但改为纯函数
  const name = computeCategoryNamespaceName(); // 与现有 getCategoryNamespaceName 相同,但改为纯函数
  return { alt, name };
}
```

然后把这个信息传给辅助函数,而不是依赖 `_catNsAlt`

```ts
export function stripCategoryPrefix(name: string, nsInfo: CategoryNsInfo): string {
  return String(name)
    .replace(new RegExp(`^\\s*(?:${nsInfo.alt})\\s*:`, 'i'), '')
    .trim();
}

export function parseCategories(wikitext: string, nsInfo: CategoryNsInfo): Parsed {
  const categories: CategoryRef[] = [];
  const ds = findDefaultSortMatches(wikitext);
  const re = new RegExp(
    `\\[\\[\\s*(?<ns>${nsInfo.alt})\\s*:\\s*(?<name>[^\\[\\]|]*?)(?:\\s*\\|\\s*(?<sortkey>[^\\[\\]]*?))?\\s*\\]\\]`,
    'gi'
  );
  // ...
}
```

调用方只需创建一次 `nsInfo` 并复用:

```ts
const nsInfo = initCategoryNsInfo();
const parsed = parseCategories(content, nsInfo);
// 后续:
const link = renderLink(row, defaultSort, nsInfo.name);
```

这样可以在保留所有现有行为与缓存的前提下,移除隐藏的全局状态,让命名空间处理更显式、更易测试。

---

### 3. 考虑使用单一的「分类块重建」路径统一 `buildAppend` / `buildInPlace`

当前你有:

- `buildAppend`(剥离所有相关内容,在底部重建分类块)
- `buildInPlace`(HotCat 风格的替换 + 在最后一个分类处插入)

在保持 HotCat 语义的同时,你可以通过显式建模「分类块」,并总是重建这个块来减少分支。

最小示意:

```ts
interface CategoryBlock {
  start: number;
  end: number;
}

function findCategoryBlock(text: string): CategoryBlock | null {
  const cats = parseCategories(text).categories;
  const dsMatches = findDefaultSortMatches(text);
  if (!cats.length && !dsMatches.length) return null;

  const start = Math.min(
    ...(cats.map(c => c.start)),
    ...(dsMatches.map(m => m.start))
  );
  const end = Math.max(
    ...(cats.map(c => c.end)),
    ...(dsMatches.map(m => m.end + 2))
  );
  return { start, end };
}

export function buildWikitext(
  original: string,
  rows: CategoryRow[],
  defaultSort: string,
  originalCats: CategoryRef[] = []
): string {
  const block = findCategoryBlock(original);
  const defaultNs = getCategoryNamespaceName();

  const lines: string[] = [];
  if (defaultSort) lines.push(`{{DEFAULTSORT:${defaultSort}}}`);
  for (const r of rows) {
    const link = renderLink(r, defaultSort, defaultNs);
    if (link) lines.push(link);
  }
  const blockText = lines.length ? `\n${lines.join('\n')}\n` : '\n';

  if (!block) return original.replace(/[ \t\r\n]+$/, '') + blockText;

  const before = original.slice(0, block.start);
  const after = original.slice(block.end);
  return before.replace(/[ \t\r\n]+$/, '') + blockText + after;
}
```

之后你可以在组装 `rows` 的过程中重新引入「就地」排序语义(使用 `_id``isReordered`),但保持只有一条路径来重建分类/DEFAULTSORT 块,而不是维护两套策略和不同的偏移规则。

这样可以移除 `findLastCategoryEnd``buildInPlace` 中按行的删除启发式,以及 `buildAppend`/`buildInPlace` 的分裂,同时通过控制 `rows` 的顺序和包含的行来保持所有可见行为。
</issue_to_address>

### 评论 4
<location path="packages/quick-cat/src/index.ts" line_range="212" />
<code_context>
  wrapper.innerHTML = svg.trim()
</code_context>
<issue_to_address>
**security (javascript.browser.security.insecure-document-method):** 在 `innerHTML``outerHTML``document.write` 等方法中使用用户可控数据是一种反模式,可能导致 XSS 漏洞。

*来源:opengrep*
</issue_to_address>

### 评论 5
<location path="packages/quick-cat/src/index.ts" line_range="212" />
<code_context>
  wrapper.innerHTML = svg.trim()
</code_context>
<issue_to_address>
**security (javascript.browser.security.insecure-innerhtml):** 在 `wrapper.innerHTML` 中使用用户可控数据是一种反模式,可能导致 XSS 漏洞。

*来源:opengrep*
</issue_to_address>

Sourcery 对开源项目免费——如果你觉得我们的评审有用,欢迎分享 ✨
帮我变得更有用!请在每条评论上点 👍 或 👎,我会根据你的反馈改进评审质量。
Original comment in English

Hey - I've found 2 security issues, 3 other issues, and left some high level feedback:

Security issues:

  • User controlled data in methods like innerHTML, outerHTML or document.write is an anti-pattern that can lead to XSS vulnerabilities (link)
  • User controlled data in a wrapper.innerHTML is an anti-pattern that can lead to XSS vulnerabilities (link)

General comments:

  • The autocomplete dropdown is rendered as a fixed portal on document.body with a very high z-index, which can overlap other modals/tooling; consider constraining it within the modal container or using a more conservative stacking context to avoid layout conflicts with other UI.
  • Several injected dependencies are typed as any (e.g. api, modal, toolbox in Ctx), which makes the code harder to reason about; tightening these to the actual MwApi/Modal/Toolbox interfaces from @inpageedit/core (or local type definitions) would improve maintainability and catch integration mistakes at compile time.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The autocomplete dropdown is rendered as a fixed portal on `document.body` with a very high z-index, which can overlap other modals/tooling; consider constraining it within the modal container or using a more conservative stacking context to avoid layout conflicts with other UI.
- Several injected dependencies are typed as `any` (e.g. `api`, `modal`, `toolbox` in `Ctx`), which makes the code harder to reason about; tightening these to the actual MwApi/Modal/Toolbox interfaces from `@inpageedit/core` (or local type definitions) would improve maintainability and catch integration mistakes at compile time.

## Individual Comments

### Comment 1
<location path="packages/quick-cat/src/parse.ts" line_range="259" />
<code_context>
+export function buildAppend(original: string, rows: CategoryRow[], defaultSort: string): string {
+  let text = stripDefaultSort(original)
+  text = stripCategoryLinks(text)
+  text = text.replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/[ \t\r\n]+$/, '')
+
+  const defaultNs = getCategoryNamespaceName()
</code_context>
<issue_to_address>
**suggestion:** Whitespace normalization may be too aggressive for some pages.

Both `buildAppend` and `buildInPlace` collapse 3+ newlines and trim leading/trailing whitespace on the entire `original` text. That can alter page layout or intentionally spaced content that’s unrelated to categories. If non-category whitespace must be preserved, consider limiting normalization to the category/defaultsort region (where links are appended/modified), or applying a narrower, more targeted rule.

Suggested implementation:

```typescript
export function buildAppend(original: string, rows: CategoryRow[], defaultSort: string): string {
  let text = stripDefaultSort(original)
  text = stripCategoryLinks(text)
  // Preserve non-category whitespace; only trim trailing spaces/tabs so we can append cleanly
  text = text.replace(/[ \t]+$/, '')

  const defaultNs = getCategoryNamespaceName()

```

`buildInPlace` currently applies similar aggressive whitespace normalization to the entire `original` text. To fully implement the suggestion and preserve non-category whitespace, make analogous changes there:

1. Remove global collapsing of 3+ newlines and trimming of leading/trailing whitespace on the entire page.
2. If needed, replace it with a narrower rule that only affects trailing spaces/tabs around the category/defaultsort insertion point, or otherwise restrict normalization to the specific region where categories/defaultsort are being updated.
</issue_to_address>

### Comment 2
<location path="packages/quick-cat/src/index.ts" line_range="133" />
<code_context>
+  submissionError: 'Submission Error',
+}
+
+let currentCtx: Ctx | null = null
+let suggestSeq = 0
+let optSeq = 0
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring global plugin state, category dialog state mutations, and autocomplete logic into small, reusable modules and per-plugin context objects to make the code easier to reason about and maintain without changing behavior.

You can keep all current behavior while reducing complexity by pulling out a few generic pieces and clarifying state vs view. Here are some concrete, incremental refactors that don’t change functionality.

### 1. Replace global mutable singletons with a per-plugin context

`currentCtx`, `_logger`, `suggestSeq`, `optSeq` are global and tied to lifecycle. You can make them explicit with a small context object and pass it around.

```ts
// quickCatContext.ts
export interface QuickCatContext {
  ctx: Ctx
  logger: typeof log
  suggestSeq: number
  optSeq: number
}

export function createQuickCatContext(ctx: Ctx): QuickCatContext {
  return {
    ctx,
    logger: (ctx as any).logger?.('quick-cat')
      ? {
          info: (...a) => (ctx as any).logger('quick-cat').info(...a),
          warn: (...a) => (ctx as any).logger('quick-cat').warn(...a),
          error: (...a) => (ctx as any).logger('quick-cat').error(...a),
        }
      : log,
    suggestSeq: 0,
    optSeq: 0,
  }
}
```

Then in your plugin entry:

```ts
apply(ctx: InPageEdit): void {
  const c = ctx as Ctx
  if ((c as any)[APPLIED_FLAG]) return
  ;(c as any)[APPLIED_FLAG] = true

  const qc = createQuickCatContext(c)

  // use qc.logger instead of global _logger/log
  // pass qc to attachAutocomplete, showModal, etc.
}
```

And in `attachAutocomplete`:

```ts
function attachAutocomplete(
  qc: QuickCatContext,
  m: any,
  input: HTMLInputElement,
  suggest: HTMLElement,
  handlers: AutocompleteHandlers = {},
): void {
  // use qc.ctx instead of ctx, qc.suggestSeq / qc.optSeq instead of globals
  suggest.id = suggest.id || `ipe-quick-cat__suggest-${++qc.suggestSeq}`
  // ...
}
```

This keeps behavior the same but removes hidden cross-plugin state and makes lifecycle easier to reason about.

---

### 2. Split state mutations from DOM updates in the dialog

`renderDialog` currently mutates `CategoryState` and DOM together (especially drag-sort and selection). Wrapping state changes in small pure helpers makes the event handlers easier to read and test.

You can move logic like selection and drag reorder into a tiny `categoryState.ts`:

```ts
// categoryState.ts
export function toggleSelection(state: CategoryState, row: CategoryRow, checked: boolean) {
  if (checked) state.selected.add(row)
  else state.selected.delete(row)
}

export function selectAll(state: CategoryState, on: boolean) {
  if (on) state.rows.forEach((r) => state.selected.add(r))
  else state.selected.clear()
}

export function deleteSelected(state: CategoryState) {
  state.rows = state.rows.filter((r) => !state.selected.has(r))
  state.selected.clear()
}

export function startDrag(state: CategoryState, row: CategoryRow) {
  state._dragIndex = state.rows.indexOf(row)
}

export function reorderRow(state: CategoryState, toIndex: number) {
  if (state._dragIndex == null) return
  const from = state._dragIndex
  const [moved] = state.rows.splice(from, 1)
  const target = from < toIndex ? toIndex - 1 : toIndex
  state.rows.splice(target, 0, moved)
  state._dragIndex = null
}
```

Then, in `renderDialog` / `createCategoryRow`, event handlers only call these helpers and then `refreshList` / `refreshToolbar`:

```ts
check.addEventListener('change', () => {
  toggleSelection(state, row, check.checked)
  refreshToolbar()
})

grip.addEventListener('pointerdown', (e) => {
  if (e.pointerType === 'mouse' && e.button !== 0) return
  e.preventDefault()
  startDrag(state, row)
  grip.setPointerCapture(e.pointerId)
  rowEl.classList.add('is-dragging')
})

deleteBtn.addEventListener('click', () => {
  deleteSelected(state)
  refreshList()
  refreshToolbar()
})

list.addEventListener('pointerup', (e) => {
  if (state._dragIndex == null) return
  const to = computeInsertIndex(e.clientY)
  reorderRow(state, to)
  refreshList()
})
```

This keeps all UI behavior identical, but makes it clearer which functions mutate state and which just update the DOM.

---

### 3. Extract autocomplete into a reusable module

`attachAutocomplete` is already quite generic. Moving it (and its related search / cache logic) into `autocomplete.ts` makes the main module shorter and reduces cognitive load.

```ts
// autocomplete.ts
export interface AutocompleteHandlers {
  onPick?: (value: string) => void
  onEnter?: () => void
}

export function attachCategoryAutocomplete(
  qc: QuickCatContext,
  m: any,
  input: HTMLInputElement,
  suggest: HTMLElement,
  handlers: AutocompleteHandlers = {},
): void {
  // current contents of attachAutocomplete, but using qc.ctx / qc.suggestSeq / qc.optSeq
}
```

Usage in `createCategoryRow` stays almost the same:

```ts
const nameSuggest = h('div', { class: 'ipe-quick-cat__suggest' })

attachCategoryAutocomplete(qc, m, nameInput, nameSuggest, {
  onPick: (cat) => {
    row.name = cat
    nameInput.value = cat
  },
})
```

Doing this for `attachAutocomplete` and `searchCategories` removes a large block from the main file without changing behavior, and makes the autocomplete logic independently testable.

---

These changes are all incremental (no behavior changes) and target the main complexity drivers: global mutable state, mixed state/DOM logic, and a large generic autocomplete implementation inside the main plugin file.
</issue_to_address>

### Comment 3
<location path="packages/quick-cat/src/parse.ts" line_range="37" />
<code_context>
+const RAW_TAGS = ['nowiki', 'pre', 'code', 'math', 'syntaxhighlight', 'source', 'timeline', 'poem', 'hiero']
+let _lastMaskedSource: string | null = null
+let _lastMasked: string | null = null
+function maskIgnoredRegions(text: string): string {
+  // Single-entry memo: parse/build steps frequently mask the same text
+  if (text === _lastMaskedSource) return _lastMasked!
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring the parsing utilities to separate concerns, eliminate hidden global state, and unify category block rebuilding so the behavior stays the same but the code is easier to understand and maintain.

The main complexity comes from mixed responsibilities and hidden global state. You can keep all current behavior while simplifying a few key areas:

---

### 1. Make `maskIgnoredRegions` purely functional and separate DEFAULTSORT handling

Right now `maskIgnoredRegions`:

- masks comments/tags/templates
- has single-entry memoization via `_lastMaskedSource` / `_lastMasked`
- has inline, hand-rolled logic to preserve DEFAULTSORT templates

You can:

1. Drop the memoization (callers already pass the text; if caching is needed later, it can be done externally).
2. Use `findDefaultSortMatches` to get ranges to preserve, and then mask other templates in a clearer pass.

Example refactor outline:

```ts
function maskIgnoredRegions(text: string): string {
  let masked = text.replace(/<!--[\s\S]*?-->/g, m => ' '.repeat(m.length));
  for (const tag of RAW_TAGS) {
    masked = masked.replace(
      new RegExp(`<${tag}(?:\\s[^>]*)?>[\\s\\S]*?<\\/${tag}>`, 'gi'),
      m => ' '.repeat(m.length)
    );
  }

  const dsRanges = findDefaultSortMatches(masked).map(m => [m.start, m.end + 2] as [number, number]);
  const chars = masked.split('');
  let depth = 0;

  for (let i = 0; i < chars.length - 1; i++) {
    if (masked.startsWith('{{', i)) {
      const inDefaultSort = dsRanges.some(([s, e]) => i >= s && i < e);
      if (!inDefaultSort) depth++;
      i++; // skip second '{'
    } else if (masked.startsWith('}}', i)) {
      if (depth > 0) depth--;
      i++; // skip second '}'
    } else if (depth > 0) {
      chars[i] = ' ';
    }
  }

  return chars.join('');
}
```

This keeps the “preserve DEFAULTSORT, mask other templates” behavior but:

- removes the global cache
- separates “find DEFAULTSORT” (already done by `findDefaultSortMatches`) from “mask templates”, reducing nested logic and special cases.

If you do want caching, consider a caller-owned helper:

```ts
class Masker {
  private cache = new Map<string, string>();
  mask(text: string): string {
    if (this.cache.has(text)) return this.cache.get(text)!;
    const masked = maskIgnoredRegions(text);
    this.cache.set(text, masked);
    return masked;
  }
}
```

---

### 2. Replace global `_catNsAlt` with an explicit context

`_catNsAlt` is global mutable state used by multiple helpers. This makes behavior depend on hidden initialization order.

You can keep caching but make it explicit and non-global:

```ts
interface CategoryNsInfo {
  alt: string;
  name: string;
}

function initCategoryNsInfo(): CategoryNsInfo {
  // existing getCategoryNamespaceAlt / getCategoryNamespaceName logic combined
  const alt = computeCategoryNamespaceAlt();   // same as current getCategoryNamespaceAlt, but pure
  const name = computeCategoryNamespaceName(); // same as current getCategoryNamespaceName, but pure
  return { alt, name };
}
```

Then pass this info into helpers instead of relying on `_catNsAlt`:

```ts
export function stripCategoryPrefix(name: string, nsInfo: CategoryNsInfo): string {
  return String(name)
    .replace(new RegExp(`^\\s*(?:${nsInfo.alt})\\s*:`, 'i'), '')
    .trim();
}

export function parseCategories(wikitext: string, nsInfo: CategoryNsInfo): Parsed {
  const categories: CategoryRef[] = [];
  const ds = findDefaultSortMatches(wikitext);
  const re = new RegExp(
    `\\[\\[\\s*(?<ns>${nsInfo.alt})\\s*:\\s*(?<name>[^\\[\\]|]*?)(?:\\s*\\|\\s*(?<sortkey>[^\\[\\]]*?))?\\s*\\]\\]`,
    'gi'
  );
  // ...
}
```

Callers create `nsInfo` once and reuse:

```ts
const nsInfo = initCategoryNsInfo();
const parsed = parseCategories(content, nsInfo);
// later:
const link = renderLink(row, defaultSort, nsInfo.name);
```

This keeps all existing behavior and caching, but removes hidden global state and makes namespace handling more explicit and testable.

---

### 3. Consider a single “category block rebuild” path to unify `buildAppend` / `buildInPlace`

You currently have:

- `buildAppend` (strip everything, rebuild bottom block)
- `buildInPlace` (HotCat-style replacements + insertion at last category)

You can keep HotCat semantics but reduce branching by modeling a “category block” explicitly and always rebuilding that block.

Minimal sketch:

```ts
interface CategoryBlock {
  start: number;
  end: number;
}

function findCategoryBlock(text: string): CategoryBlock | null {
  const cats = parseCategories(text).categories;
  const dsMatches = findDefaultSortMatches(text);
  if (!cats.length && !dsMatches.length) return null;

  const start = Math.min(
    ...(cats.map(c => c.start)),
    ...(dsMatches.map(m => m.start))
  );
  const end = Math.max(
    ...(cats.map(c => c.end)),
    ...(dsMatches.map(m => m.end + 2))
  );
  return { start, end };
}

export function buildWikitext(
  original: string,
  rows: CategoryRow[],
  defaultSort: string,
  originalCats: CategoryRef[] = []
): string {
  const block = findCategoryBlock(original);
  const defaultNs = getCategoryNamespaceName();

  const lines: string[] = [];
  if (defaultSort) lines.push(`{{DEFAULTSORT:${defaultSort}}}`);
  for (const r of rows) {
    const link = renderLink(r, defaultSort, defaultNs);
    if (link) lines.push(link);
  }
  const blockText = lines.length ? `\n${lines.join('\n')}\n` : '\n';

  if (!block) return original.replace(/[ \t\r\n]+$/, '') + blockText;

  const before = original.slice(0, block.start);
  const after = original.slice(block.end);
  return before.replace(/[ \t\r\n]+$/, '') + blockText + after;
}
```

You can then reintroduce “in-place” ordering semantics (using `_id` and `isReordered`) inside how `rows` is assembled, but keep *one* path that rebuilds the category/DEFAULTSORT block instead of maintaining two separate strategies with different offset rules.

This removes `findLastCategoryEnd`, the per-line deletion heuristics in `buildInPlace`, and the `buildAppend`/`buildInPlace` split, while preserving all visible behavior by controlling how `rows` is ordered and which rows are included.
</issue_to_address>

### Comment 4
<location path="packages/quick-cat/src/index.ts" line_range="212" />
<code_context>
  wrapper.innerHTML = svg.trim()
</code_context>
<issue_to_address>
**security (javascript.browser.security.insecure-document-method):** User controlled data in methods like `innerHTML`, `outerHTML` or `document.write` is an anti-pattern that can lead to XSS vulnerabilities

*Source: opengrep*
</issue_to_address>

### Comment 5
<location path="packages/quick-cat/src/index.ts" line_range="212" />
<code_context>
  wrapper.innerHTML = svg.trim()
</code_context>
<issue_to_address>
**security (javascript.browser.security.insecure-innerhtml):** User controlled data in a `wrapper.innerHTML` is an anti-pattern that can lead to XSS vulnerabilities

*Source: opengrep*
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread packages/quick-cat/src/parse.ts Outdated
Comment thread packages/quick-cat/src/index.ts Outdated
Comment thread packages/quick-cat/src/parse.ts
Comment thread packages/quick-cat/src/index.ts Outdated
Comment thread packages/quick-cat/src/index.ts Outdated
@dragon-fish

Copy link
Copy Markdown
Member

?!猫猫!?

- register messages for all zh variants (IPE matches exact language codes)
- reuse official messages via ctx.\$\$; simplify t() with \$\$ template tags
- default summary as plain constant; drop summaryPh placeholder
- refresh page on editconflict so a retry succeeds
- migrate entry/autocomplete to .tsx (JSX + jsx-dom; add tsconfig + dep)
@Wecury

Wecury commented Aug 3, 2026

Copy link
Copy Markdown
Author

@sourcery-ai review

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="packages/quick-cat/src/index.tsx" line_range="491-499" />
<code_context>
+    }
+  } catch (err) {
+    logger.error('save failed:', err)
+    const code = (err as any)?.code || (err as any)?.data?.error?.code
+    if (code === 'pagedeleted' || code === 'editconflict') {
+      // Refresh so a retry submits with the latest baserevid
+      try {
+        state.page = await qc.ctx.wikiPage.newFromTitle(state.title, undefined, undefined, true)
+      } catch {
+        /* keep the old page object */
+      }
+      modal.notify('warning', {
+        title: t('submissionError'),
+        content: (
</code_context>
<issue_to_address>
**suggestion (bug_risk):** 在处理编辑冲突之后,对话框状态没有与刷新后的页面同步。

在发生 `pagedeleted` / `editconflict` 时,只有 `state.page` 通过 `newFromTitle` 被刷新;`content``categories``rows``defaultSort` 依然是旧修订版本的数据。之后的保存会在新的 `baserevid` 上提交过期的全文内容,可能覆盖中途发生的分类变更。请在刷新后重新解析最新页面并写入 `state.content``state.categories``state.rows`(以及 `defaultSort`),或者要求用户重新打开对话框,以确保重试时基于最新修订版本。

建议实现:

```typescript
    if (code === 'pagedeleted' || code === 'editconflict') {
      // Refresh so a retry submits with the latest baserevid
      try {
        const refreshedPage = await qc.ctx.wikiPage.newFromTitle(
          state.title,
          undefined,
          undefined,
          true,
        )
        state.page = refreshedPage

        /**
         * Synchronize dialog state with the refreshed page so that subsequent retries
         * submit content/categories/rows/defaultSort based on the latest revision.
         *
         * NOTE: The parsing helper used here MUST match the one used when the dialog
         * is initially opened, to avoid divergence between representations.
         */
        const {
          content,
          categories,
          rows,
          defaultSort,
        } = qc.ctx.quickCat.parsePage(refreshedPage)

        state.content = content
        state.categories = categories
        state.rows = rows
        state.defaultSort = defaultSort
      } catch {
        /* keep the old page object and dialog state */
      }
      modal.notify('warning', {
        title: t('submissionError'),
        content: (
          <div>
            <p>
              <strong>{String((err as Error)?.message || err)}</strong>
            </p>
            <p>{t('retry')}</p>
          </div>
        ),
        closeAfter: 15000,
      })
      return

```

1. 将 `qc.ctx.quickCat.parsePage(refreshedPage)` 替换为该文件中其它地方实际使用的辅助函数,用来从 `wikiPage`/标题推导出 `content``categories``rows``defaultSort`(例如快速分类对话框首次打开时调用的那个函数)。
2. 如果解析辅助函数返回的属性名称不同,请相应调整解构以及对 `state.content``state.categories``state.rows``state.defaultSort` 的赋值。
3. 如果这些状态字段中有任何是间接派生的(例如 `rows``categories` 构建),请在这里重用相同的逻辑,而不是调用一个新的辅助函数,以保持行为一致。
</issue_to_address>

### Comment 2
<location path="packages/quick-cat/src/parse.ts" line_range="313" />
<code_context>
+
+// Reorder: rebuild a contiguous block in place, else strip and append at the end.
+// Otherwise: edit in place and insert new categories after the last link (HotCat).
+export function buildWikitext(
+  original: string,
+  rows: CategoryRow[],
</code_context>
<issue_to_address>
**issue (complexity):** 建议抽取重排与原位编辑两条路径、共享的编辑应用逻辑以及带掩码的扫描辅助函数,以简化并澄清整个模块的控制流。

你可以通过一些不改变行为的小型抽取来降低该模块的复杂度:

1. **在 `buildWikitext` 中拆分两种策略**

当前 `buildWikitext` 将“重排/重建”和“原位编辑”两条路径混在一起。将它们抽取出来可以让控制流更容易理解和测试:

```ts
function buildReorderedWikitext(
  original: string,
  rows: CategoryRow[],
  defaultSort: string,
  originalCats: CategoryRef[],
  nsInfo: CategoryNsInfo
): string {
  const lines = renderCategoryLines(rows, defaultSort, nsInfo)
  const dsMatches = findDefaultSortMatches(original)
  const block = findCategoryBlock(originalCats, dsMatches)
  if (block && lines.length && isBlockContiguous(original, block, originalCats, dsMatches)) {
    return rebuildBlock(original, block, lines)
  }
  let text = stripDefaultSort(original)
  text = stripCategoryLinks(text, nsInfo)
  text = text.replace(/[ \t\r\n]+$/, '')
  if (lines.length === 0) return `${text}\n`
  return `${text}\n${lines.join('\n')}\n`
}

function buildInPlaceWikitext(
  original: string,
  rows: CategoryRow[],
  defaultSort: string,
  originalCats: CategoryRef[],
  nsInfo: CategoryNsInfo
): string {
  const rowById = new Map<number, CategoryRow>()
  for (const r of rows) if (r._id != null) rowById.set(r._id, r)
  const additions = rows.filter((r) => r._id == null)

  const dsMatches = findDefaultSortMatches(original)
  const edits: Array<{ start: number; end: number; text: string }> = []

  // existing category edits...
  // existing DEFAULTSORT edits...

  const textAfterEdits = applyTextEdits(original, edits)

  return insertNewCategories(textAfterEdits, additions, defaultSort, dsMatches, nsInfo)
}
```

这样 `buildWikitext` 就变得更具声明性:

```ts
export function buildWikitext(
  original: string,
  rows: CategoryRow[],
  defaultSort: string,
  originalCats: CategoryRef[],
  nsInfo: CategoryNsInfo
): string {
  if (isReordered(rows, originalCats)) {
    return buildReorderedWikitext(original, rows, defaultSort, originalCats, nsInfo)
  }
  return buildInPlaceWikitext(original, rows, defaultSort, originalCats, nsInfo)
}
```

2. **抽取通用的“从末尾应用编辑”逻辑**

你已经在 `buildWikitext` 中实现了这段逻辑;将其提取到一个辅助函数中可以让意图更清晰,也便于在未来的文本转换中复用:

```ts
interface TextEdit {
  start: number
  end: number
  text: string
}

function applyTextEdits(original: string, edits: TextEdit[]): string {
  const sorted = [...edits].sort((a, b) => b.start - a.start)
  let result = original
  for (const e of sorted) {
    result = result.slice(0, e.start) + e.text + result.slice(e.end)
  }
  return result
}
```

然后在 `buildWikitext` 中:

```ts
const textAfterEdits = applyTextEdits(original, edits)
// tail trimming as today
let text = textAfterEdits.replace(/[ \t\r\n]+$/, '')
```

3. **统一用于 strip/find 辅助函数的带掩码扫描逻辑**

`stripCategoryLinks``stripDefaultSort``findLastCategoryEnd` 都在重复“掩码 + 正则 + 收集区间”的模式。一个很小的抽象即可消除重复,并集中对 `maskIgnoredRegions` 的耦合:

```ts
type Range = [number, number]

function findMaskedRanges(text: string, re: RegExp): Range[] {
  const masked = maskIgnoredRegions(text)
  const ranges: Range[] = []
  let m: RegExpExecArray | null
  while ((m = re.exec(masked))) {
    ranges.push([m.index, m.index + m[0].length])
  }
  return ranges
}
```

`stripCategoryLinks` 变为:

```ts
export function stripCategoryLinks(text: string, nsInfo: CategoryNsInfo): string {
  const re = new RegExp(`\\[\\[\\s*(?:${nsInfo.alt})\\s*:[^\\]]*\\]\\]`, 'gi')
  const ranges = findMaskedRanges(text, re)

  let out = text
  for (let i = ranges.length - 1; i >= 0; i--) {
    let [s, e] = ranges[i]
    // existing line-leading handling...
    out = out.slice(0, s) + out.slice(e)
  }
  return out
}
```

`findLastCategoryEnd` 可以复用同一个辅助函数,而无需重新实现循环:

```ts
function findLastCategoryEnd(text: string, nsInfo: CategoryNsInfo): number {
  const re = new RegExp(
    `\\[\\[\\s*(?:${nsInfo.alt})\\s*:\\s*[^\\[\\]|]*?(?:\\s*\\|\\s*[^\\[\\]]*?)?\\s*\\]\\]`,
    'gi'
  )
  const ranges = findMaskedRanges(text, re)
  return ranges.length ? ranges[ranges.length - 1][1] : -1
}
```

这些抽取保持全部现有行为,同时降低理解负担:`buildWikitext` 变成在两种明确策略之间的调度器,文本编辑应用逻辑被封装,带掩码的扫描逻辑集中管理而不再到处重复。
</issue_to_address>

### Comment 3
<location path="packages/quick-cat/src/index.tsx" line_range="237" />
<code_context>
+  ) as HTMLElement
+}
+
+function renderDialog(qc: QuickCatContext, m: any, state: CategoryState): void {
+  const { t } = qc
+  const root = <div className="ipe-quick-cat" /> as HTMLDivElement
</code_context>
<issue_to_address>
**issue (complexity):** 建议从 renderDialog、saveCategories 和 showModal 中抽取更小的辅助函数,将视图绑定、拖拽逻辑、冲突 UI 和状态初始化等职责拆分开来。

你可以在不改变行为的前提下,通过从 `renderDialog``saveCategories``showModal` 中提取几个聚焦的辅助函数来降低复杂度。

### 1. 将 `renderDialog` 拆分为更小的视图辅助函数

目前 `renderDialog` 负责工具栏、列表、拖拽逻辑、添加栏、默认排序、选项以及所有事件绑定。你可以保持现有行为,但通过返回元素和回调的更小构造函数,使逻辑更容易理解。

示例结构:

```ts
function createToolbar(
  qc: QuickCatContext,
  state: CategoryState,
  list: HTMLDivElement,
  refreshList: () => void
) {
  const checkAll = document.createElement('input');
  const countEl = document.createElement('span');
  const deleteBtn = document.createElement('button');

  const refreshToolbar = () => {
    const n = state.rows.length;
    const sel = state.selected.size;
    checkAll.checked = n > 0 && sel === n;
    checkAll.indeterminate = sel > 0 && sel < n;
    countEl.textContent = qc.t('selectedCount', sel);
    deleteBtn.disabled = sel === 0;
  };

  checkAll.addEventListener('change', () => {
    selectAll(state, checkAll.checked);
    // keep existing DOM sync here...
    refreshToolbar();
  });

  deleteBtn.addEventListener('click', () => {
    deleteSelected(state);
    refreshList();
    refreshToolbar();
  });

  const toolbar = (
    <div className="ipe-quick-cat__toolbar">
      {/* ... */}
    </div>
  ) as HTMLElement;

  return { toolbar, refreshToolbar };
}
```

然后由 `renderDialog` 进行编排:

```ts
function renderDialog(qc: QuickCatContext, m: any, state: CategoryState): void {
  const root = <div className="ipe-quick-cat" /> as HTMLDivElement;
  const list = <div className="ipe-quick-cat__list" /> as HTMLDivElement;

  const refreshList = () => {
    // as today, but only list-related work
  };

  const { toolbar, refreshToolbar } = createToolbar(qc, state, list, refreshList);

  // reuse existing `createAddBar`, `createCategoryRow`, etc.
  const addBar = createAddBar(qc, m, state, refreshList);
  const { dsLabel } = createDefaultSortSection(qc, m, state, list);
  const options = createOptionsSection(qc, state);

  root.append(toolbar, list, addBar, dsLabel, options);
  m.setContent(root);

  refreshList();
}
```

这样可以保留你的逻辑,同时让人可以在不用通读整个函数的情况下理解或修改工具栏或选项部分。

### 2. 将拖拽逻辑从 `renderDialog` 中移出

`computeInsertIndex``clearIndicators` 以及指针事件监听器目前全部闭包在 `renderDialog` 中。你可以把它们移动到一个只接受 `state``list` 的辅助函数里。

```ts
function attachDragHandlers(
  state: CategoryState,
  list: HTMLDivElement,
  refreshList: () => void
) {
  const computeInsertIndex = (clientY: number): number => {
    const rows = [...list.querySelectorAll('.ipe-quick-cat__row')];
    for (let i = 0; i < rows.length; i++) {
      const r = rows[i].getBoundingClientRect();
      if (clientY < r.top + r.height / 2) return i;
    }
    return rows.length;
  };

  const clearIndicators = () => {
    list
      .querySelectorAll('.ipe-quick-cat__row')
      .forEach((el) => el.classList.remove('is-drop-before', 'is-drop-after'));
  };

  list.addEventListener('pointermove', (e) => {
    if (state._dragIndex == null) return;
    // existing logic...
  });

  list.addEventListener('pointerup', (e) => {
    if (state._dragIndex == null) return;
    reorderRow(state, computeInsertIndex(e.clientY));
    refreshList();
  });

  list.addEventListener('pointercancel', () => {
    endDrag(state);
    // existing class removal...
  });
}
```

然后在 `renderDialog` 中:

```ts
const list = <div className="ipe-quick-cat__list" /> as HTMLDivElement;
const refreshList = () => { /* as today */ };

attachDragHandlers(state, list, refreshList);
```

这样可以将拖拽行为隔离出来,大幅精简 `renderDialog`,并让其更易于测试和修改。

### 3. 将 `saveCategories` 中的冲突错误 UI 抽取出来

针对 `pagedeleted` / `editconflict` 的内联 JSX 将布局和控制流混在一起:

```ts
modal.notify('warning', {
  title: t('submissionError'),
  content: (
    <div>
      <p>
        <strong>{String((err as Error)?.message || err)}</strong>
      </p>
      <p>{t('retry')}</p>
    </div>
  ),
  closeAfter: 15000,
});
```

把这段逻辑移到一个辅助函数中,可以让主函数只呈现控制流:

```ts
function notifyConflictError(
  qc: QuickCatContext,
  err: unknown
) {
  const { ctx, t } = qc;
  const msg = String((err as Error)?.message || err);
  ctx.modal.notify('warning', {
    title: t('submissionError'),
    content: (
      <div>
        <p>
          <strong>{msg}</strong>
        </p>
        <p>{t('retry')}</p>
      </div>
    ),
    closeAfter: 15000,
  });
}
```

然后在 `saveCategories` 中:

```ts
if (code === 'pagedeleted' || code === 'editconflict') {
  // refresh page...
  notifyConflictError(qc, err);
  return;
}
```

这样可以保持当前行为,同时让主流程更加简洁。

### 4. 将 `showModal` 拆分为“状态初始化”和“模态框绑定”

`showModal` 同时处理偏好设置、模态框配置、页面加载、解析以及状态构建。把状态初始化单独抽取到一个辅助函数中可以让整体流程更加清晰:

```ts
async function initCategoryState(
  qc: QuickCatContext,
  title: string,
  defaultSummary: string,
  defaultMinor: boolean
): Promise<CategoryState> {
  const { ctx, nsInfo } = qc;
  const page = await ctx.wikiPage.newFromTitle(title);
  const content = page.revisions?.[0]?.content ?? '';
  const parsed = parseCategories(content, nsInfo);

  return {
    title,
    pageName:
      ctx.currentPage?.wikiTitle?.getPrefixedText?.() ||
      ((mw.config.get('wgPageName') as string) || title).replace(/_/g, ' '),
    page,
    content,
    categories: parsed.categories,
    originalDefaultSort: parsed.defaultSort,
    defaultSort: parsed.defaultSort,
    summary: defaultSummary,
    minor: defaultMinor,
    reloadAfterSave: true,
    selected: new Set(),
    _dragIndex: null,
    rows: parsed.categories.map((c) => ({
      _id: c._id,
      name: c.name,
      sortkey: c.sortkey || parsed.defaultSort,
      ns: c.ns || null,
    })),
  };
}
```

然后 `showModal` 变为:

```ts
async function showModal(qc: QuickCatContext) {
  // compute title, preferences, create modal...
  let state: CategoryState | null = null;

  try {
    state = await initCategoryState(qc, title, defaultSummary, defaultMinor);
    renderDialog(qc, m, state);
  } catch (err) {
    // existing error handling
  }
}
```

这样在保留全部行为的同时,让每个职责更加集中,更易于维护。
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
帮我变得更有用!请在每条评论上点击 👍 或 👎,我会根据这些反馈改进之后的代码审查。
Original comment in English

Hey - I've found 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="packages/quick-cat/src/index.tsx" line_range="491-499" />
<code_context>
+    }
+  } catch (err) {
+    logger.error('save failed:', err)
+    const code = (err as any)?.code || (err as any)?.data?.error?.code
+    if (code === 'pagedeleted' || code === 'editconflict') {
+      // Refresh so a retry submits with the latest baserevid
+      try {
+        state.page = await qc.ctx.wikiPage.newFromTitle(state.title, undefined, undefined, true)
+      } catch {
+        /* keep the old page object */
+      }
+      modal.notify('warning', {
+        title: t('submissionError'),
+        content: (
</code_context>
<issue_to_address>
**suggestion (bug_risk):** After edit conflict handling, the dialog state is not synchronized with the refreshed page.

On `pagedeleted` / `editconflict`, only `state.page` is refreshed via `newFromTitle`; `content`, `categories`, `rows`, and `defaultSort` still reflect the old revision. A subsequent save will then submit stale fulltext content against the new `baserevid`, potentially overwriting intervening category changes. Please either re‑parse the latest page into `state.content` / `state.categories` / `state.rows` (and `defaultSort`) after refresh, or require the user to reopen the dialog so retries use the up‑to‑date revision.

Suggested implementation:

```typescript
    if (code === 'pagedeleted' || code === 'editconflict') {
      // Refresh so a retry submits with the latest baserevid
      try {
        const refreshedPage = await qc.ctx.wikiPage.newFromTitle(
          state.title,
          undefined,
          undefined,
          true,
        )
        state.page = refreshedPage

        /**
         * Synchronize dialog state with the refreshed page so that subsequent retries
         * submit content/categories/rows/defaultSort based on the latest revision.
         *
         * NOTE: The parsing helper used here MUST match the one used when the dialog
         * is initially opened, to avoid divergence between representations.
         */
        const {
          content,
          categories,
          rows,
          defaultSort,
        } = qc.ctx.quickCat.parsePage(refreshedPage)

        state.content = content
        state.categories = categories
        state.rows = rows
        state.defaultSort = defaultSort
      } catch {
        /* keep the old page object and dialog state */
      }
      modal.notify('warning', {
        title: t('submissionError'),
        content: (
          <div>
            <p>
              <strong>{String((err as Error)?.message || err)}</strong>
            </p>
            <p>{t('retry')}</p>
          </div>
        ),
        closeAfter: 15000,
      })
      return

```

1. Replace `qc.ctx.quickCat.parsePage(refreshedPage)` with the actual helper used elsewhere in this file to derive `content`, `categories`, `rows`, and `defaultSort` from a `wikiPage`/title (for example, whatever is called when the quick-cat dialog is first opened).
2. If the parsing helper returns differently named properties, adjust the destructuring and the assignments to `state.content`, `state.categories`, `state.rows`, and `state.defaultSort` to match.
3. If any of these state fields are derived indirectly (e.g. `rows` built from `categories`), reuse that same logic here instead of calling a new helper, to keep behavior consistent.
</issue_to_address>

### Comment 2
<location path="packages/quick-cat/src/parse.ts" line_range="313" />
<code_context>
+
+// Reorder: rebuild a contiguous block in place, else strip and append at the end.
+// Otherwise: edit in place and insert new categories after the last link (HotCat).
+export function buildWikitext(
+  original: string,
+  rows: CategoryRow[],
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the separate reorder and in‑place paths, shared edit-application logic, and masked scanning helpers to simplify and clarify this module’s control flow.

You can reduce complexity in this module with a couple of small extractions that don’t change behavior:

1. **Separate the two strategies in `buildWikitext`**

`buildWikitext` currently mixes “reorder/rebuild” and “in‑place edit” paths. Extracting them makes the control flow much easier to follow and test:

```ts
function buildReorderedWikitext(
  original: string,
  rows: CategoryRow[],
  defaultSort: string,
  originalCats: CategoryRef[],
  nsInfo: CategoryNsInfo
): string {
  const lines = renderCategoryLines(rows, defaultSort, nsInfo)
  const dsMatches = findDefaultSortMatches(original)
  const block = findCategoryBlock(originalCats, dsMatches)
  if (block && lines.length && isBlockContiguous(original, block, originalCats, dsMatches)) {
    return rebuildBlock(original, block, lines)
  }
  let text = stripDefaultSort(original)
  text = stripCategoryLinks(text, nsInfo)
  text = text.replace(/[ \t\r\n]+$/, '')
  if (lines.length === 0) return `${text}\n`
  return `${text}\n${lines.join('\n')}\n`
}

function buildInPlaceWikitext(
  original: string,
  rows: CategoryRow[],
  defaultSort: string,
  originalCats: CategoryRef[],
  nsInfo: CategoryNsInfo
): string {
  const rowById = new Map<number, CategoryRow>()
  for (const r of rows) if (r._id != null) rowById.set(r._id, r)
  const additions = rows.filter((r) => r._id == null)

  const dsMatches = findDefaultSortMatches(original)
  const edits: Array<{ start: number; end: number; text: string }> = []

  // existing category edits...
  // existing DEFAULTSORT edits...

  const textAfterEdits = applyTextEdits(original, edits)

  return insertNewCategories(textAfterEdits, additions, defaultSort, dsMatches, nsInfo)
}
```

Then `buildWikitext` becomes declarative:

```ts
export function buildWikitext(
  original: string,
  rows: CategoryRow[],
  defaultSort: string,
  originalCats: CategoryRef[],
  nsInfo: CategoryNsInfo
): string {
  if (isReordered(rows, originalCats)) {
    return buildReorderedWikitext(original, rows, defaultSort, originalCats, nsInfo)
  }
  return buildInPlaceWikitext(original, rows, defaultSort, originalCats, nsInfo)
}
```

2. **Extract the generic “apply edits from the end” logic**

You already implement this in `buildWikitext`; pulling it into a helper makes the intent clearer and reusable for any future text transforms:

```ts
interface TextEdit {
  start: number
  end: number
  text: string
}

function applyTextEdits(original: string, edits: TextEdit[]): string {
  const sorted = [...edits].sort((a, b) => b.start - a.start)
  let result = original
  for (const e of sorted) {
    result = result.slice(0, e.start) + e.text + result.slice(e.end)
  }
  return result
}
```

Then in `buildWikitext`:

```ts
const textAfterEdits = applyTextEdits(original, edits)
// tail trimming as today
let text = textAfterEdits.replace(/[ \t\r\n]+$/, '')
```

3. **Unify masked scanning for strip/find helpers**

`stripCategoryLinks`, `stripDefaultSort`, and `findLastCategoryEnd` all repeat “mask + regex + collect ranges”. A tiny abstraction removes duplication and centralises the coupling to `maskIgnoredRegions`:

```ts
type Range = [number, number]

function findMaskedRanges(text: string, re: RegExp): Range[] {
  const masked = maskIgnoredRegions(text)
  const ranges: Range[] = []
  let m: RegExpExecArray | null
  while ((m = re.exec(masked))) {
    ranges.push([m.index, m.index + m[0].length])
  }
  return ranges
}
```

`stripCategoryLinks` becomes:

```ts
export function stripCategoryLinks(text: string, nsInfo: CategoryNsInfo): string {
  const re = new RegExp(`\\[\\[\\s*(?:${nsInfo.alt})\\s*:[^\\]]*\\]\\]`, 'gi')
  const ranges = findMaskedRanges(text, re)

  let out = text
  for (let i = ranges.length - 1; i >= 0; i--) {
    let [s, e] = ranges[i]
    // existing line-leading handling...
    out = out.slice(0, s) + out.slice(e)
  }
  return out
}
```

`findLastCategoryEnd` can reuse the same helper instead of reimplementing the loop:

```ts
function findLastCategoryEnd(text: string, nsInfo: CategoryNsInfo): number {
  const re = new RegExp(
    `\\[\\[\\s*(?:${nsInfo.alt})\\s*:\\s*[^\\[\\]|]*?(?:\\s*\\|\\s*[^\\[\\]]*?)?\\s*\\]\\]`,
    'gi'
  )
  const ranges = findMaskedRanges(text, re)
  return ranges.length ? ranges[ranges.length - 1][1] : -1
}
```

These extractions keep all behaviour but reduce the mental load: `buildWikitext` becomes a dispatcher between two clear strategies, text-edit application is encapsulated, and masked scanning logic is centralised instead of repeated.
</issue_to_address>

### Comment 3
<location path="packages/quick-cat/src/index.tsx" line_range="237" />
<code_context>
+  ) as HTMLElement
+}
+
+function renderDialog(qc: QuickCatContext, m: any, state: CategoryState): void {
+  const { t } = qc
+  const root = <div className="ipe-quick-cat" /> as HTMLDivElement
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting smaller helper functions from renderDialog, saveCategories, and showModal to separate view wiring, drag logic, conflict UI, and state initialization responsibilities.

You can reduce the complexity without changing behavior by extracting a few focused helpers out of `renderDialog`, `saveCategories`, and `showModal`.

### 1. Split `renderDialog` into smaller view helpers

Right now `renderDialog` owns toolbar, list, drag logic, add bar, default-sort, options, and all event wiring. You can keep the same behavior but make it easier to reason about by returning elements + callbacks from smaller creators.

Example structure:

```ts
function createToolbar(
  qc: QuickCatContext,
  state: CategoryState,
  list: HTMLDivElement,
  refreshList: () => void
) {
  const checkAll = document.createElement('input');
  const countEl = document.createElement('span');
  const deleteBtn = document.createElement('button');

  const refreshToolbar = () => {
    const n = state.rows.length;
    const sel = state.selected.size;
    checkAll.checked = n > 0 && sel === n;
    checkAll.indeterminate = sel > 0 && sel < n;
    countEl.textContent = qc.t('selectedCount', sel);
    deleteBtn.disabled = sel === 0;
  };

  checkAll.addEventListener('change', () => {
    selectAll(state, checkAll.checked);
    // keep existing DOM sync here...
    refreshToolbar();
  });

  deleteBtn.addEventListener('click', () => {
    deleteSelected(state);
    refreshList();
    refreshToolbar();
  });

  const toolbar = (
    <div className="ipe-quick-cat__toolbar">
      {/* ... */}
    </div>
  ) as HTMLElement;

  return { toolbar, refreshToolbar };
}
```

Then `renderDialog` orchestrates:

```ts
function renderDialog(qc: QuickCatContext, m: any, state: CategoryState): void {
  const root = <div className="ipe-quick-cat" /> as HTMLDivElement;
  const list = <div className="ipe-quick-cat__list" /> as HTMLDivElement;

  const refreshList = () => {
    // as today, but only list-related work
  };

  const { toolbar, refreshToolbar } = createToolbar(qc, state, list, refreshList);

  // reuse existing `createAddBar`, `createCategoryRow`, etc.
  const addBar = createAddBar(qc, m, state, refreshList);
  const { dsLabel } = createDefaultSortSection(qc, m, state, list);
  const options = createOptionsSection(qc, state);

  root.append(toolbar, list, addBar, dsLabel, options);
  m.setContent(root);

  refreshList();
}
```

This keeps your logic but makes it possible to understand/modify toolbar or options without scanning the full function.

### 2. Move drag-and-drop logic out of `renderDialog`

`computeInsertIndex`, `clearIndicators`, and pointer listeners currently close over the whole `renderDialog`. You can move them into a helper that takes `state` and `list` as arguments.

```ts
function attachDragHandlers(
  state: CategoryState,
  list: HTMLDivElement,
  refreshList: () => void
) {
  const computeInsertIndex = (clientY: number): number => {
    const rows = [...list.querySelectorAll('.ipe-quick-cat__row')];
    for (let i = 0; i < rows.length; i++) {
      const r = rows[i].getBoundingClientRect();
      if (clientY < r.top + r.height / 2) return i;
    }
    return rows.length;
  };

  const clearIndicators = () => {
    list
      .querySelectorAll('.ipe-quick-cat__row')
      .forEach((el) => el.classList.remove('is-drop-before', 'is-drop-after'));
  };

  list.addEventListener('pointermove', (e) => {
    if (state._dragIndex == null) return;
    // existing logic...
  });

  list.addEventListener('pointerup', (e) => {
    if (state._dragIndex == null) return;
    reorderRow(state, computeInsertIndex(e.clientY));
    refreshList();
  });

  list.addEventListener('pointercancel', () => {
    endDrag(state);
    // existing class removal...
  });
}
```

Then in `renderDialog`:

```ts
const list = <div className="ipe-quick-cat__list" /> as HTMLDivElement;
const refreshList = () => { /* as today */ };

attachDragHandlers(state, list, refreshList);
```

This isolates drag behavior and trims `renderDialog` significantly, making it easier to test and change.

### 3. Extract the conflict error UI from `saveCategories`

The inline JSX for `pagedeleted` / `editconflict` mixes layout with control flow:

```ts
modal.notify('warning', {
  title: t('submissionError'),
  content: (
    <div>
      <p>
        <strong>{String((err as Error)?.message || err)}</strong>
      </p>
      <p>{t('retry')}</p>
    </div>
  ),
  closeAfter: 15000,
});
```

Move this into a helper so the main function reads as control flow only:

```ts
function notifyConflictError(
  qc: QuickCatContext,
  err: unknown
) {
  const { ctx, t } = qc;
  const msg = String((err as Error)?.message || err);
  ctx.modal.notify('warning', {
    title: t('submissionError'),
    content: (
      <div>
        <p>
          <strong>{msg}</strong>
        </p>
        <p>{t('retry')}</p>
      </div>
    ),
    closeAfter: 15000,
  });
}
```

Then in `saveCategories`:

```ts
if (code === 'pagedeleted' || code === 'editconflict') {
  // refresh page...
  notifyConflictError(qc, err);
  return;
}
```

### 4. Split `showModal` into state init vs. modal wiring

`showModal` handles preferences, modal configuration, page loading, parsing, and state construction. Separating the state init into a helper makes the flow clearer:

```ts
async function initCategoryState(
  qc: QuickCatContext,
  title: string,
  defaultSummary: string,
  defaultMinor: boolean
): Promise<CategoryState> {
  const { ctx, nsInfo } = qc;
  const page = await ctx.wikiPage.newFromTitle(title);
  const content = page.revisions?.[0]?.content ?? '';
  const parsed = parseCategories(content, nsInfo);

  return {
    title,
    pageName:
      ctx.currentPage?.wikiTitle?.getPrefixedText?.() ||
      ((mw.config.get('wgPageName') as string) || title).replace(/_/g, ' '),
    page,
    content,
    categories: parsed.categories,
    originalDefaultSort: parsed.defaultSort,
    defaultSort: parsed.defaultSort,
    summary: defaultSummary,
    minor: defaultMinor,
    reloadAfterSave: true,
    selected: new Set(),
    _dragIndex: null,
    rows: parsed.categories.map((c) => ({
      _id: c._id,
      name: c.name,
      sortkey: c.sortkey || parsed.defaultSort,
      ns: c.ns || null,
    })),
  };
}
```

Then `showModal` becomes:

```ts
async function showModal(qc: QuickCatContext) {
  // compute title, preferences, create modal...
  let state: CategoryState | null = null;

  try {
    state = await initCategoryState(qc, title, defaultSummary, defaultMinor);
    renderDialog(qc, m, state);
  } catch (err) {
    // existing error handling
  }
}
```

This preserves all behavior while making each responsibility narrower and easier to maintain.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread packages/quick-cat/src/index.tsx Outdated
Comment thread packages/quick-cat/src/parse.ts Outdated
Comment thread packages/quick-cat/src/index.tsx Outdated
Wecury added 6 commits August 3, 2026 22:13
…ync)

- split buildWikitext into reorder/in-place builders; extract applyTextEdits
  and findMaskedRanges
- extract toolbar/drag/options/conflict helpers from renderDialog/saveCategories
- reload full state on editconflict/pagedeleted so a retry uses fresh data
- on editconflict/pagedeleted warn once and keep edits; resubmit overwrites
- report open/save via analytics/event (aligns with quick-edit)
- Quick Cat options now live in the editor preferences tab
- autocomplete merges opensearch + allpages and sorts by relevance
- default-sort placeholder uses title without namespace prefix
- remove hidden loading text; shorten description and comments
- Toolbar Add inserts a blank row; default-sort above list, toolbar below; ds input styled exactly like the name input (matched selector specificity); modal max-height lowered; empty-state centered
- Hide autocomplete when its input is detached (adding before suggestions render no longer leaves a stray menu); Enter on a name moves to the sort key
- Save validates existing rows keep a name and rejects duplicates (case-insensitive, prefix-stripped)
- Drop (optional) from sort-key placeholder; unify SVG icons via dom.ts; trim comments
@Wecury

Wecury commented Aug 6, 2026

Copy link
Copy Markdown
Author

@sourcery-ai review

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - 我发现了 1 个问题。

给 AI Agents 的提示
请根据本次代码审查中的评论进行修改:

## 单条评论

### 评论 1
<location path="packages/quick-cat/src/autocomplete.tsx" line_range="143-145" />
<code_context>
+  handlers: AutocompleteHandlers = {}
+): void {
+  // Render as a fixed portal on body so the scrollable list can't clip it
+  const hideSuggest = () => {
+    suggest.remove()
+    suggest.textContent = ''
+    optionEls = []
+    activeIndex = 0
</code_context>
<issue_to_address>
**suggestion (bug_risk):** 将每一行的 `suggest` 元素复用为一个全局、固定定位的 portal,在行重新渲染时可能会遗留脱离行的下拉框。

由于 `hideSuggest()` 只会在交互时被调用,如果列表刷新并替换了该行的 DOM,那么在下一次用户操作之前,被固定在 `document.body` 上的 `suggest` 节点仍会保持挂载状态。为避免这些“孤儿”下拉框,你可以:要么确保在某一行重新渲染/移除前调用 `hideSuggest()`,要么改为在每个模态层面使用单一 portal 元素,并在模态层面而不是每一行来管理其生命周期。

建议实现方式:

```typescript
export function attachAutocomplete(
  qc: QuickCatContext,
  m: any,
  input: HTMLInputElement,
  suggest: HTMLElement,
  handlers: AutocompleteHandlers = {}
): void {
  let detachObserver: MutationObserver | null = null

  // Render as a fixed portal on body so the scrollable list can't clip it
  const hideSuggest = () => {
    suggest.remove()
    suggest.textContent = ''
    optionEls = []
    activeIndex = 0
    input.setAttribute('aria-expanded', 'false')
    input.removeAttribute('aria-activedescendant')

    if (detachObserver) {
      detachObserver.disconnect()
      detachObserver = null
    }
  }

  // Ensure the dropdown is cleaned up if the owning row/input is removed
  if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined' && !detachObserver) {
    detachObserver = new MutationObserver(() => {
      // When the owning input is detached (e.g. row re-rendered), hide/cleanup the portal
      if (!input.isConnected) {
        hideSuggest()
      }
    })

    if (document.body) {
      detachObserver.observe(document.body, { childList: true, subtree: true })
    }
  }

  const positionSuggest = () => {

```

- 如果当前有任何调用点会手动移除 `suggest` 元素(例如直接调用 `suggest.remove()`),应改为调用 `hideSuggest()`,这样既能断开 `MutationObserver`,又能统一重置内部状态。
- 如果你使用的渲染框架已经提供了行级别的清理钩子(例如组件的 `onremove`/`onunmount`),你也可以在该钩子中额外调用一次 `hideSuggest()` 以增加保障;这里的 observer 是防御性措施,不依赖框架钩子,但两者结合可以让销毁过程更加清晰。
</issue_to_address>

Sourcery 对开源项目免费——如果你觉得我们的评审有帮助,欢迎分享 ✨
帮我变得更有用!请在每条评论上点 👍 或 👎,你的反馈会用来改进后续的评审质量。
Original comment in English

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="packages/quick-cat/src/autocomplete.tsx" line_range="143-145" />
<code_context>
+  handlers: AutocompleteHandlers = {}
+): void {
+  // Render as a fixed portal on body so the scrollable list can't clip it
+  const hideSuggest = () => {
+    suggest.remove()
+    suggest.textContent = ''
+    optionEls = []
+    activeIndex = 0
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Reusing the per-row `suggest` element as a global fixed-position portal can leave detached dropdowns around when rows are re-rendered.

Because `hideSuggest()` is only called on interaction, a list refresh that replaces the row DOM can leave the fixed-position `suggest` node still attached to `document.body` until the next user action. To avoid these orphaned dropdowns, either ensure `hideSuggest()` runs before a row is re-rendered/removed, or switch to a single portal element per modal whose lifecycle is managed at the modal level instead of per row.

Suggested implementation:

```typescript
export function attachAutocomplete(
  qc: QuickCatContext,
  m: any,
  input: HTMLInputElement,
  suggest: HTMLElement,
  handlers: AutocompleteHandlers = {}
): void {
  let detachObserver: MutationObserver | null = null

  // Render as a fixed portal on body so the scrollable list can't clip it
  const hideSuggest = () => {
    suggest.remove()
    suggest.textContent = ''
    optionEls = []
    activeIndex = 0
    input.setAttribute('aria-expanded', 'false')
    input.removeAttribute('aria-activedescendant')

    if (detachObserver) {
      detachObserver.disconnect()
      detachObserver = null
    }
  }

  // Ensure the dropdown is cleaned up if the owning row/input is removed
  if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined' && !detachObserver) {
    detachObserver = new MutationObserver(() => {
      // When the owning input is detached (e.g. row re-rendered), hide/cleanup the portal
      if (!input.isConnected) {
        hideSuggest()
      }
    })

    if (document.body) {
      detachObserver.observe(document.body, { childList: true, subtree: true })
    }
  }

  const positionSuggest = () => {

```

- If there are any existing call sites manually removing the `suggest` element (e.g. `suggest.remove()`), they should be updated to call `hideSuggest()` instead so the `MutationObserver` is also disconnected and internal state is reset consistently.
- If your rendering framework already exposes a row-level cleanup hook (e.g. a component `onremove`/`onunmount`), you can optionally also call `hideSuggest()` from that hook as an extra guarantee; the observer here is defensive and does not require framework hooks, but combining both can make teardown more explicit.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread packages/quick-cat/src/autocomplete.tsx
Watch for the owning input leaving the DOM (row re-rendered via add/delete/drag) and hide the open dropdown, so the fixed body portal never lingers as an orphan after the list refreshes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants