Skip to content

fix(sync): content_preview / content_text から HTML タグを除去 - #498

Merged
otomatty merged 1 commit into
developfrom
claude/pedantic-einstein
Apr 6, 2026
Merged

fix(sync): content_preview / content_text から HTML タグを除去#498
otomatty merged 1 commit into
developfrom
claude/pedantic-einstein

Conversation

@otomatty

@otomatty otomatty commented Apr 6, 2026

Copy link
Copy Markdown
Owner

Summary

  • Y.XmlText.toString() / toJSON()<bold> 等の HTML タグを返す問題を修正
  • CollaborationManager.extractText()toDelta() を使用し、書式属性なしの純粋なテキストのみを抽出するように変更
  • Hocuspocus サーバーに extractTextFromYXml ユーティリティを追加し、ドキュメント保存時に content_textcontent_preview を正しく永続化

Changes

  1. src/lib/collaboration/CollaborationManager.ts: node.toJSON()node.toDelta() ベースに変更
  2. server/hocuspocus/src/extractPlainTextFromYXml.ts (新規): Y.XmlFragment から再帰的にプレーンテキストを抽出するユーティリティ
  3. server/hocuspocus/src/index.ts: saveDocumentToDbcontent_text(検索用)と content_preview(一覧表示用)を抽出・保存
  4. server/hocuspocus/src/extractPlainTextFromYXml.test.ts (新規): 6 テストケース(空 fragment、単純テキスト、複数パラグラフ、bold/italic ストリップ、ネスト要素)

Test plan

  • extractPlainTextFromYXml.test.ts — 6 テスト全通過
  • CollaborationManager.test.ts — 7 テスト全通過
  • ESLint エラーなし
  • Prettier フォーマット済み
  • 既に DB に保存済みの HTML タグ付きデータは、次回のドキュメント保存時に自動修正される想定

Closes #497

🤖 Generated with Claude Code


Open with Devin

Summary by CodeRabbit

リリースノート

  • 新機能

    • ページコンテンツから自動的にプレーンテキストを抽出し、ページプレビューを生成するようになりました。
    • 抽出されたテキストはデータベースに保存され、検索や表示に活用されます。
  • バグ修正

    • テキスト抽出時にフォーマットタグが除外され、より正確なプレーンテキストが取得されるようになりました。
  • テスト

    • テキスト抽出機能の包括的なテストスイートが追加されました。

@coderabbitai

coderabbitai Bot commented Apr 6, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a4f922c6-a257-4e32-891d-2e0234ce7bcd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Yjs XMLテキストから平文を抽出する新しい extractTextFromYXml 関数を追加し、toString() の代わりに toDelta() を使用してHTML形式のタグ混入を防止。Hocuspocusのデータベース永続化とCollaborationManagerで同アプローチを統合。

Changes

Cohort / File(s) Summary
テキスト抽出実装
server/hocuspocus/src/extractPlainTextFromYXml.ts, server/hocuspocus/src/extractPlainTextFromYXml.test.ts
Yjs XmlFragmentから平文を再帰的に抽出する新関数を実装。toDelta()insert 文字列のみを連結し、HTMLタグ混入を回避。複数パラグラフ間に改行を挿入。包括的なテストケース(空フラグメント、テキスト抽出、ネスト要素、フォーマット属性処理)を追加。
データベース永続化更新
server/hocuspocus/src/index.ts
extractTextFromYXml をインポートして saveDocumentToDb で使用。page_contents.content_text に抽出平文を保存し、upsert時も更新対象に追加。pages.content_preview を最初の300文字で更新。
CollaborationManager テキスト抽出修正
src/lib/collaboration/CollaborationManager.ts
Y.XmlText の平文抽出を toJSON() から toDelta() ベースに変更。op.insert が文字列の場合のみ追加することで、XML形式タグの混入を排除。

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 Delta の波より真実の言葉を紡ぎ出す
タグなき清き文を集めて
データベースに優しく刻む
検索も プレビューも
今日からきれいに輝くよ ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR タイトルは問題の本質(Y.XmlText の HTML タグ除去)を明確に説明しており、変更内容と完全に一致しています。
Linked Issues check ✅ Passed PR は Issue #497 の全ての要件を満たしています: toDelta() ベースの実装、テストケース追加、CollaborationManager と extractPlainTextFromYXml の両修正が完了しています。
Out of Scope Changes check ✅ Passed 全ての変更は Issue #497 の範囲内です: extractPlainTextFromYXml の新規作成、CollaborationManager の修正、index.ts での DB 永続化対応、テストケース追加のみです。

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/pedantic-einstein

Comment @coderabbitai help to get the list of available commands and usage tips.

@otomatty otomatty self-assigned this Apr 6, 2026
@otomatty
otomatty changed the base branch from main to develop April 6, 2026 05:55

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request implements plain text extraction from Yjs XML fragments using the toDelta method to avoid HTML tags, updating both the server-side storage logic and the client-side collaboration manager. Feedback identifies a potential bug in CollaborationManager.ts where delta operations could cause unintended newlines and suggests trimming the extracted text for consistency.

Comment on lines +289 to +293
for (const op of node.toDelta()) {
if (typeof op.insert === "string") {
parts.push(op.insert);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

node.toDelta() は、テキスト内の書式(太字、斜体など)が切り替わる箇所で複数の op を返します。現在の実装では、各 op.insert を個別に parts 配列に push していますが、最終的に parts.join("\n")(307行目)で連結されるため、書式の境界ごとに意図しない改行が挿入されてしまいます。

XmlText ノード内のテキストは、改行を挟まずに結合してから parts に追加することで、以前の toJSON() を使用していた時と同様の構造(1つの XmlText ノードにつき1つの parts 要素)を維持しつつ、HTML タグのみを除去できます。

        let text = "";
        for (const op of node.toDelta()) {
          if (typeof op.insert === "string") {
            text += op.insert;
          }
        }
        parts.push(text);

};

walk(fragment);
return parts.join("");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

クライアント側の CollaborationManager.extractText との整合性を保ち、検索インデックスや一覧プレビューでの表示品質を向上させるため、抽出したテキストの前後にある不要な空白や改行を除去(trim())することを推奨します。

Suggested change
return parts.join("");
return parts.join("").trim();

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/lib/collaboration/CollaborationManager.ts (1)

285-307: ⚠️ Potential issue | 🟠 Major

toDelta() 使用により、同一 Y.XmlText 内に不要な改行が混入しています。

Lines 289–293 で toDelta() の各 op ごとに parts.push し、Line 307 で parts.join("\n") しているため、"Hello " + "world" + "!" のように同一 Y.XmlText 内に複数の操作がある場合、Hello \nworld\n! になってしまいます。Y.XmlText ごとに内部の文字列を先に連結してから parts に積む形に修正してください。

修正案
   private extractText(fragment: Y.XmlFragment): string {
     const parts: string[] = [];
     const walk = (node: Y.XmlFragment | Y.XmlElement | Y.XmlText) => {
       if (node instanceof Y.XmlText) {
         // toDelta() を使い書式属性なしの純粋なテキストのみを抽出する。
         // toString() / toJSON() は <bold> 等の HTML タグを返すため使用しない。
         // Use toDelta() to extract pure text without formatting attributes.
         // toString() / toJSON() return HTML-like tags (<bold>, etc.) so we avoid them.
-        for (const op of node.toDelta()) {
-          if (typeof op.insert === "string") {
-            parts.push(op.insert);
-          }
-        }
+        let text = "";
+        for (const op of node.toDelta()) {
+          if (typeof op.insert === "string") {
+            text += op.insert;
+          }
+        }
+        if (text.length > 0) {
+          parts.push(text);
+        }
       } else {
         for (const child of node.toArray()) {
           if (
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/collaboration/CollaborationManager.ts` around lines 285 - 307, The
current walk(fragment) logic pushes each op.insert from node.toDelta()
individually causing intra-Y.XmlText fragments to become separated by "\n";
change the Y.XmlText/Y.XmlElement handling so that for each node (where you
currently iterate for (const op of node.toDelta())), you first concatenate all
string inserts from that single node into a temp string (e.g., accumulate
op.insert values), then push that combined string once into parts; keep the
existing behavior of walking child nodes and the final parts.join("\n").trim()
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/hocuspocus/src/index.ts`:
- Around line 206-223: The two separate queries updating page_contents and pages
must be executed atomically to avoid partial updates: wrap the INSERT/ON
CONFLICT into page_contents and the UPDATE of pages (setting content_preview) in
a single DB transaction (BEGIN ... COMMIT) using the same client, and ensure you
ROLLBACK on any error; reference the page_contents insert/ON CONFLICT statement
and the UPDATE pages SET content_preview operation that use pageId,
encodedState, contentText and contentPreview so both succeed or both are rolled
back.

---

Outside diff comments:
In `@src/lib/collaboration/CollaborationManager.ts`:
- Around line 285-307: The current walk(fragment) logic pushes each op.insert
from node.toDelta() individually causing intra-Y.XmlText fragments to become
separated by "\n"; change the Y.XmlText/Y.XmlElement handling so that for each
node (where you currently iterate for (const op of node.toDelta())), you first
concatenate all string inserts from that single node into a temp string (e.g.,
accumulate op.insert values), then push that combined string once into parts;
keep the existing behavior of walking child nodes and the final
parts.join("\n").trim() unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e0efb7ba-7008-4622-abd2-f0167ef17c06

📥 Commits

Reviewing files that changed from the base of the PR and between a05b312 and aa05f19.

📒 Files selected for processing (4)
  • server/hocuspocus/src/extractPlainTextFromYXml.test.ts
  • server/hocuspocus/src/extractPlainTextFromYXml.ts
  • server/hocuspocus/src/index.ts
  • src/lib/collaboration/CollaborationManager.ts

Comment on lines 206 to 223
await client.query(
`
INSERT INTO page_contents (page_id, ydoc_state, version, content_text, updated_at)
VALUES ($1, $2, 1, '', NOW())
VALUES ($1, $2, 1, $3, NOW())
ON CONFLICT (page_id) DO UPDATE
SET ydoc_state = EXCLUDED.ydoc_state,
content_text = EXCLUDED.content_text,
version = page_contents.version + 1,
updated_at = NOW()
`,
[pageId, encodedState],
[pageId, encodedState, contentText],
);
// content_preview は pages テーブルに格納 / content_preview is stored in the pages table
await client.query(`UPDATE pages SET content_preview = $2, updated_at = NOW() WHERE id = $1`, [
pageId,
contentPreview,
]);
} finally {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

page_contentspages 更新は同一トランザクションで行うべきです。

Line [206]-[223] は 2 クエリが分離しており、途中失敗で content_text だけ更新される不整合が起きます。保存処理は原子的にしてください。

💡 修正案
 async function saveDocumentToDb(pageId: string, document: Y.Doc): Promise<void> {
@@
   const client = await getPool().connect();
   try {
+    await client.query("BEGIN");
     await client.query(
@@
       [pageId, encodedState, contentText],
     );
@@
       contentPreview,
     ]);
+    await client.query("COMMIT");
+  } catch (error) {
+    await client.query("ROLLBACK");
+    throw error;
   } finally {
     client.release();
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await client.query(
`
INSERT INTO page_contents (page_id, ydoc_state, version, content_text, updated_at)
VALUES ($1, $2, 1, '', NOW())
VALUES ($1, $2, 1, $3, NOW())
ON CONFLICT (page_id) DO UPDATE
SET ydoc_state = EXCLUDED.ydoc_state,
content_text = EXCLUDED.content_text,
version = page_contents.version + 1,
updated_at = NOW()
`,
[pageId, encodedState],
[pageId, encodedState, contentText],
);
// content_preview は pages テーブルに格納 / content_preview is stored in the pages table
await client.query(`UPDATE pages SET content_preview = $2, updated_at = NOW() WHERE id = $1`, [
pageId,
contentPreview,
]);
} finally {
await client.query("BEGIN");
await client.query(
`
INSERT INTO page_contents (page_id, ydoc_state, version, content_text, updated_at)
VALUES ($1, $2, 1, $3, NOW())
ON CONFLICT (page_id) DO UPDATE
SET ydoc_state = EXCLUDED.ydoc_state,
content_text = EXCLUDED.content_text,
version = page_contents.version + 1,
updated_at = NOW()
`,
[pageId, encodedState, contentText],
);
// content_preview は pages テーブルに格納 / content_preview is stored in the pages table
await client.query(`UPDATE pages SET content_preview = $2, updated_at = NOW() WHERE id = $1`, [
pageId,
contentPreview,
]);
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/hocuspocus/src/index.ts` around lines 206 - 223, The two separate
queries updating page_contents and pages must be executed atomically to avoid
partial updates: wrap the INSERT/ON CONFLICT into page_contents and the UPDATE
of pages (setting content_preview) in a single DB transaction (BEGIN ... COMMIT)
using the same client, and ensure you ROLLBACK on any error; reference the
page_contents insert/ON CONFLICT statement and the UPDATE pages SET
content_preview operation that use pageId, encodedState, contentText and
contentPreview so both succeed or both are rolled back.

@otomatty
otomatty enabled auto-merge (squash) April 6, 2026 06:01

@devin-ai-integration devin-ai-integration 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.

Devin Review found 1 potential issue.

View 4 additional findings in Devin Review.

Open in Devin Review

Comment on lines +289 to 296
for (const op of node.toDelta()) {
if (typeof op.insert === "string") {
parts.push(op.insert);
}
}
} else {
for (const child of node.toArray()) {
if (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Client-side extractText inserts spurious newlines between formatted text runs

The old code pushed one string per XmlText node via node.toJSON(), so parts.join("\n") only added newlines between block-level text nodes (paragraphs). The new code pushes one string per delta operation (one per formatting run), but parts.join("\n") is unchanged. This means for text like "Hello world!", toDelta() produces 3 entries ("Hello ", "world", "!"), and the join produces "Hello \nworld\n!" instead of "Hello world!".

This corrupts content_text sent to the server in local-mode saves (used for full-text search at server/api/src/routes/search.ts:53), breaks content duplication detection (detectContentDuplication), and also affects the fireAndForgetSave on page unload. The server-side implementation at server/hocuspocus/src/extractPlainTextFromYXml.ts:45 correctly uses parts.join("") with explicit newline insertion between block elements.

(Refers to lines 289-307)

Prompt for agents
In CollaborationManager.ts, the extractText method at line 281 now pushes one string per delta op into `parts`, but still uses `parts.join("\n")` to concatenate. This inserts newlines between formatting runs within the same XmlText node (e.g. bold/italic text). The server-side implementation in extractPlainTextFromYXml.ts solves this correctly by using `parts.join("")` with explicit newline insertion between block elements.

Two approaches to fix:
1. Align the client implementation with the server-side one: use `parts.join("")` and insert explicit newlines between block-level elements (like XmlElement children that are not the last child).
2. Alternatively, import and reuse `extractTextFromYXml` from a shared location, though currently the server utility is in server/hocuspocus/src/ which isn't accessible from the frontend. Consider extracting the utility to a shared package or duplicating the correct algorithm.

The key change needed: within the XmlText branch, accumulate all delta inserts into a single string (or push them individually), but do NOT join all parts with "\n". Only add newlines between block-level elements.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

…ntent_text

Y.XmlText.toString() / toJSON() return XML-formatted strings with tags
like <bold>, <italic> etc. This caused HTML tags to leak into
content_preview (page list) and content_text (search index).

Replace with toDelta() which returns raw insert strings without
formatting attributes. Also add extractTextFromYXml utility to
hocuspocus server so it persists content_text and content_preview
on document save.

Closes #497

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@otomatty
otomatty force-pushed the claude/pedantic-einstein branch from aa05f19 to 98e967f Compare April 6, 2026 06:07
@otomatty
otomatty merged commit a87e64b into develop Apr 6, 2026
9 of 10 checks passed
@otomatty
otomatty deleted the claude/pedantic-einstein branch April 6, 2026 06:07
@otomatty otomatty mentioned this pull request Apr 6, 2026
11 tasks
@coderabbitai coderabbitai Bot mentioned this pull request Apr 7, 2026
11 tasks
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.

fix(sync): content_preview / content_text に Y.XmlText の HTML タグが混入する

1 participant