前言
最近我開始在一套以 Kanboard 為核心的專案管理系統開發任務內容外掛。Kanboard 原生任務描述使用 Markdown,一般的文字說明、清單和連結都沒有問題;但當任務開始同時承載需求規格、圖片、附件、參考文件、流程圖與互動成果時,單一 Markdown 欄位就逐漸不夠用了。
我希望使用者可以像編輯文件一樣,在任務中依序加入段落、標題、附件、Markdown 文件,未來也能加入互動式 HTML 和跨職能流程圖。這些內容不只要能編輯,還必須在任務摘要、唯讀頁與公開分享中正確呈現。
真正困難的地方不是做出一個 React 編輯器,而是不能為了新功能破壞 Kanboard 原本已經存在的能力:搜尋、Email 通知、活動紀錄、JSON-RPC API、匯出與附件權限都要繼續運作。外掛如果發生問題,也必須能快速停用,讓任務回到原生閱讀方式。
這篇文章記錄 KnockersTaskBlocks 的開發起點,以及我為什麼最後採用了「結構化 Task Document+Markdown 相容內容」的雙層設計。
原本只是想讓任務內容更好整理
最初的需求並不複雜:不要再把所有內容塞在同一個大型文字欄位,而是讓每一段內容都成為可以新增、編輯、排序與刪除的獨立區塊。
第一階段預計支援的內容包括:
- 段落與標題
- 項目清單、編號清單與檢核清單
- 提示區塊與分隔線
- 圖片與一般附件
- Markdown 文件
- Kanboard 原生的任務與外部連結
這樣的介面看起來像一般的 block editor,但放進既有系統後,資料問題很快就出現了。
如果只把區塊內容轉回 Markdown 儲存,圖片、附件與未來的互動式元件會失去完整結構;如果只保存 JSON,Kanboard 原本只讀取 tasks.description 的功能又會看不到內容。
因此第一個要決定的不是 React 元件長什麼樣子,而是:任務內容究竟要以哪一份資料為準?
為什麼不直接修改 Kanboard 核心?
直接修改核心表單和資料表,短期看起來最快,卻會讓後續維護變得困難:
- Kanboard 更新可能覆蓋修改。
- 第三方外掛無法預期核心行為已經改變。
- 發生問題時不能只停用單一功能。
- 很難區分上游程式與專案客製內容。
最後我決定把整個功能放進獨立 Plugin,透過 Kanboard 提供的 template hook、template override、權限模型與原生附件介面完成整合。
這項決策也形成一條開發底線:
未啟用外掛的專案,行為必須和原生 Kanboard 一致;停用外掛後,既有任務仍然必須可以閱讀。
因此外掛不是全站強制取代原生描述,而是由專案管理者逐一啟用。這讓新功能可以先在測試專案使用,不需要一次改變所有人的工作方式。
Task Document 成為完整資料來源
外掛啟用後,每一筆任務可以擁有一份獨立的 Task Document。文件本身是有順序的區塊集合:
{
"schemaVersion": 1,
"blocks": [
{
"id": "block-introduction",
"type": "heading",
"version": 1,
"content": {
"text": "需求背景",
"level": 2
}
},
{
"id": "block-summary",
"type": "paragraph",
"version": 1,
"content": {
"text": "整理這項功能需要處理的使用情境。"
}
}
]
}
每個區塊都有穩定 ID、類型、版本與內容。ID 用來辨識同一個區塊,version 則讓未來能針對單一區塊格式進行 migration,而不需要一次重寫整份文件。
Task Document 是外掛中的完整資料來源,也就是 source of truth。區塊順序、格式與專屬設定都以這份 JSON 為準,不再嘗試從 Markdown 反向猜測原始結構。
但 Kanboard 原生功能仍然只認得 Markdown
結構化 JSON 解決了編輯問題,卻產生另一個更實際的問題。
Kanboard 原生搜尋、Email、活動紀錄與部分 API 並不知道 Task Document 的存在。外掛停用後,原生任務頁也不會讀取外掛資料表。如果把所有內容只放在 JSON,等於讓任務內容被綁死在外掛裡。
最後的做法是每次儲存 Task Document 時,同步產生一份 Markdown 相容內容,也就是 Content Projection。
例如前面的文件會投影成:
## 需求背景
整理這項功能需要處理的使用情境。
不同區塊有各自的投影規則:
| 區塊 | Markdown 相容內容 |
|---|---|
| 標題 | 對應層級的 Markdown heading |
| 段落 | 純文字段落 |
| 項目清單 | - 開頭的清單 |
| 檢核清單 | - [ ] 或 - [x] |
| 提示 | Markdown blockquote |
| 分隔線 | --- |
| 圖片或附件 | 檔名與說明文字 |
| 無法降級的互動內容 | 明確的文字提示 |
這份 projection 不是第二份可自由編輯的內容,也不是完整備份。它的用途是讓不理解區塊格式的既有功能,仍然能取得有意義、可搜尋和可閱讀的文字。
React 區塊編輯器
│
▼
結構化 Task Document ──→ 版本驗證與 Migration
│
├──→ 外掛 Renderer 顯示完整內容
│
└──→ Markdown Projection
│
└──→ Kanboard 原生描述與既有功能
這個設計讓新功能與舊系統之間不需要二選一:外掛畫面使用完整結構,既有 Kanboard 功能則使用相容內容。
附件不應該被外掛重新發明一次
圖片和附件是另一個容易產生兩份資料的地方。
如果外掛自己建立上傳目錄與權限規則,就必須重新處理檔案大小、MIME type、下載授權、刪除與孤兒檔案。更麻煩的是,同一份附件可能同時出現在原生任務和 Task Document 中,卻由兩套系統管理。
因此區塊只保存 fileId、替代文字與 caption 等引用資訊,附件本體仍由 Kanboard 原生的任務附件模型管理。顯示時再根據目前使用者和任務權限查詢真正的檔名、類型與下載位置,不信任 JSON 內自行傳入的 metadata。
這個原則後來也延伸到任務連結與外部連結:能沿用 Kanboard 原生資源與授權模型的資料,就不在外掛內建立第二份來源。
文件需要版本,也需要避免互相覆蓋
Task Document 除了 schemaVersion,每次成功儲存還會增加文件 revision。
使用者開啟編輯器時會取得目前 revision,儲存時必須帶回相同版本。假如另一個瀏覽器或 API Client 已經先修改文件,server 不會讓較舊的內容直接覆蓋新版,而是回報 revision conflict。
儲存流程需要在同一個 transaction 中完成:
- 驗證使用者與任務權限。
- 確認專案已啟用 Task Blocks。
- 驗證文件與每個區塊的 schema。
- 比對 expected revision。
- 儲存新的 Task Document。
- 產生並同步 Markdown projection。
- 保存 revision history。
這項設計原本只是為了避免兩個使用者互相覆蓋,後來在加入 AI Agent API 時變得更重要:Agent 也必須先讀取最新文件,再以明確 revision 寫入,不能無條件覆寫使用者內容。
前端使用 React,但安全邊界仍在 PHP
區塊選擇器、排序與編輯體驗使用 React 製作,再由 Vite 編譯成外掛內的固定 JavaScript 和 CSS。正式環境不需要執行 Node.js,也不需要把整套 Kanboard 改成 React 應用程式。
React 在這裡是一個嵌入既有頁面的 editor island。PHP 仍然負責:
- 專案與任務權限
- CSRF 驗證
- Server-side schema validation
- Revision conflict
- 資料庫 transaction
- 附件 ownership
- 安全的唯讀資料輸出
前端驗證可以讓錯誤提早顯示,但不能成為唯一安全邊界。即使有人跳過編輯器直接呼叫 endpoint,server 仍然必須拒絕未知區塊、無效資料與未授權資源。
舊任務與停用外掛後怎麼辦?
外掛不能假設所有既有任務都會立即改成區塊格式。
我沒有採用一次性的批次轉換,而是讓舊任務繼續使用原生 Markdown;只有在專案啟用外掛並開始建立 Task Document 後,才進入區塊模式。這避免自動轉換大量既有描述後,才發現部分 Markdown 結構無法正確還原。
回滾時也不刪除 Task Document 資料表。停用外掛後,Kanboard 直接顯示最後一次產生的 Markdown 相容內容;修復程式後重新啟用,完整區塊資料仍然存在。
對我來說,這是外掛架構是否成立的重要驗收:
新功能失效時,系統應該降級成較簡單但仍可閱讀的狀態,而不是讓任務內容一起消失。
第一版完成後,新的問題才剛開始
當基礎區塊可以新增、排序、儲存和顯示後,外掛很快遇到下一個架構問題:同一個區塊類型分散在設定檔、PHP validator、Markdown projection、摘要模板與 React editor 中維護。只要漏掉其中一處,就可能發生「編輯器可以新增,但任務摘要無法呈現」的情況。
接著加入互動式 HTML 時,又碰到更明顯的安全邊界:使用者貼上的 JavaScript 不能直接進入 Kanboard 頁面,原本打算使用的 srcdoc 方案也受到正式環境 CSP 限制。
跨職能流程圖則帶來另一類問題:React Flow 的畫面狀態不能直接等同於長期保存的文件資料。
這些問題後來分別促成 Block Runtime、獨立 HTML runner 與流程圖 Domain JSON。它們不是一開始就全部設計完成,而是在外掛真的加入新區塊後,逐步暴露出來的需求。
這次開發得到的幾個結論
回頭看 KnockersTaskBlocks 的起點,最重要的不是選了哪一套 block editor,而是先固定新舊系統之間的資料邊界:
- 不修改 Kanboard 核心,讓功能可以獨立升級與停用。
- 結構化 Task Document 保存完整內容,不從 Markdown 反推結構。
- 每次儲存同步產生 Markdown projection,保留原生功能與降級閱讀。
- 附件與連結沿用 Kanboard 原生資源,不建立第二套 ownership。
- 前端負責編輯體驗,PHP 仍是驗證、權限與資料一致性的權威。
- 使用專案 opt-in 和可回滾部署,避免一次改變整個系統。
如果一開始只關注 React 編輯器,很容易做出可以操作的畫面,卻在搜尋、API、權限或停用外掛時失去內容。對既有系統進行延伸開發時,真正困難的通常不是新功能本身,而是如何讓新資料模型和舊系統長期共存。
下一篇會先記錄基礎區塊增加後遇到的維護問題:Kanboard 內容區塊越做越多後:Block Runtime 重構實錄。等區塊的驗證、投影與呈現介面收斂後,再進入互動式 HTML 與跨職能流程圖等重型區塊。
