diff --git a/CLAUDE.md b/CLAUDE.md index 170bbc0..b03c63a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่า wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md) wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (ชื่อ fallback ของ ?addindemo/ไฟล์ที่มี token ไม่มี owner) wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ) -wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด, ห้าม top-level Office access) · **authService + ticketService = ticket auth ของ e-Court ทางเดียว ไม่มี login แล้ว** (ยกเลิก Keycloak → pass-through token/owner → ticket 2026-10-27, `docs/PLAN-ADDIN-AUTH.md` ตาม `docs/requirement/addin-workflow.md` — surface เหลือ signInForDemo (dev-only) / signInWithTicket / getAccessToken / renewTokenFromTicket / getUsername; ไฟล์ที่ stamp custom properties `ticket_data`+`HmacSha256` มา = `detect()` ของ TaskpaneApp เรียก signInWithTicket: ตรวจ HMAC ฝั่ง client (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET — UX gate) → exp → session localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ทั้งก้อน) → ขอ token ที่ `/api/documents/token` แบบ body ticket ผูก fid; ไม่มี ticket/HMAC ไม่ตรง/หมดอายุ = phase `no-access` แยกข้อความตามสาเหตุ; apiClient โดน 401 = renew จาก ticket 1 ครั้ง ยังไม่ผ่าน = AuthRequiredError → no-access) · **versioningService = ของจริง** (ปลด mock 2026-10-27 — uploadVersion/getVersions/getDownloadLink ยิงกลุ่ม `/api/documents/*` ผ่าน apiClient (Bearer same-origin ผ่าน withBasePath — `NEXT_PUBLIC_ADDIN_API_BASE_URL` ตั้งเฉพาะวัน deploy คนละ origin ต้องรวม basePath); เอกสารระบุด้วย fid ใน token — พารามิเตอร์ documentId/templateId เหลือตาม signature เดิมไม่ส่งถึง server และ **ห้ามส่ง userId/userName/templateId ใน form (contract 2026-10-22 ส่งมา = 400)**; mock เหลือเฉพาะโหมด `?addindemo` ผ่าน `authService.isDemoMode()` — memory + metadata persist ลง localStorage ต่อ documentId, download-link คืน about:blank; unit test tests/wordaddin/versioningService.test.ts คุมทั้งสองโหมด) +wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด — helper `supportedContentControls`/`supportsAllContentControlTypes` อยู่ที่ **metadataService** แชร์กับ `readContentControlFields` (fields ที่แนบตอนสร้าง version เก็บ CC ทุกตัวแก้ 2026-10-28 — เดิมเหลือแค่ rich ตัวเดียว), ห้าม top-level Office access) · **authService + ticketService = ticket auth ของ e-Court ทางเดียว ไม่มี login แล้ว** (ยกเลิก Keycloak → pass-through token/owner → ticket 2026-10-27, `docs/PLAN-ADDIN-AUTH.md` ตาม `docs/requirement/addin-workflow.md` — surface เหลือ signInForDemo (dev-only) / signInWithTicket / getAccessToken / renewTokenFromTicket / getUsername; ไฟล์ที่ stamp custom properties `ticket_data`+`HmacSha256` มา = `detect()` ของ TaskpaneApp เรียก signInWithTicket: ตรวจ HMAC ฝั่ง client (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET — UX gate) → exp → session localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ทั้งก้อน) → ขอ token ที่ `/api/documents/token` แบบ body ticket ผูก fid; ไม่มี ticket/HMAC ไม่ตรง/หมดอายุ = phase `no-access` แยกข้อความตามสาเหตุ; apiClient โดน 401 = renew จาก ticket 1 ครั้ง ยังไม่ผ่าน = AuthRequiredError → no-access) · **versioningService = ของจริง** (ปลด mock 2026-10-27 — uploadVersion/getVersions/getDownloadLink ยิงกลุ่ม `/api/documents/*` ผ่าน apiClient (Bearer same-origin ผ่าน withBasePath — `NEXT_PUBLIC_ADDIN_API_BASE_URL` ตั้งเฉพาะวัน deploy คนละ origin ต้องรวม basePath); เอกสารระบุด้วย fid ใน token — พารามิเตอร์ documentId/templateId เหลือตาม signature เดิมไม่ส่งถึง server และ **ห้ามส่ง userId/userName/templateId ใน form (contract 2026-10-22 ส่งมา = 400)**; mock เหลือเฉพาะโหมด `?addindemo` ผ่าน `authService.isDemoMode()` — memory + metadata persist ลง localStorage ต่อ documentId, download-link คืน about:blank; unit test tests/wordaddin/versioningService.test.ts คุมทั้งสองโหมด) utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม) diff --git a/docs/PLAN-DOCVER.md b/docs/PLAN-DOCVER.md index 64de641..23271c9 100644 --- a/docs/PLAN-DOCVER.md +++ b/docs/PLAN-DOCVER.md @@ -80,7 +80,7 @@ multipart/form-data: | `file` | File (.docx) | บังคับ — ≤ 100MB, magic bytes `PK\x03\x04` | | `pdf` | File (.pdf) | optional — `size === 0` = ถือว่าไม่มี (add-in แนบ blob เปล่าได้) | | `clientVersionId` | string | optional ≤100 — GUID ที่ client generate เอง | -| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` (≤1,000 keys · **ความยาวค่าไม่จำกัด** 2026-10-27 ตามคำขอผู้ใช้ — เอกสาร 100–200 หน้า; jsonb รับได้ถึง 255MB) | +| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` จาก content controls ทุกชนิด (add-in ใช้ `Document.getContentControls()` บน WordApi 1.5+; host ต่ำกว่าเห็นเฉพาะ rich text — ดู `docs/addin-ui-flow.md` §9 ข้อ 7) (≤1,000 keys · **ความยาวค่าไม่จำกัด** 2026-10-27 ตามคำขอผู้ใช้ — เอกสาร 100–200 หน้า; jsonb รับได้ถึง 255MB) | | `message` | string | optional — commit message ≤2048 (trim ว่าง = NULL) | | `fileName` | string | optional — ชื่อไฟล์ดั้งเดิม (ผ่าน `decodeUploadFilename` แล้ว) | diff --git a/docs/addin-ui-flow.md b/docs/addin-ui-flow.md index 7739cb5..01dd32b 100644 --- a/docs/addin-ui-flow.md +++ b/docs/addin-ui-flow.md @@ -278,7 +278,7 @@ response: `{ url: string, expiresAt: string }` — UI เปิด `url` ใน 4. UI ทั้งหมดภาษาไทย — ข้อความสำเนาได้จากไฟล์ต้นฉบับที่อ้างด้านบน 5. Task pane แคบ (side panel) — layout เป็นคอลัมน์เดียว ปุ่ม/actions ต้องรองรับความกว้างจำกัด 6. id ของ Content Control **ไม่เสถียรข้าม session/coauthor** (docstring ของ Office.js เอง) — เครื่องมือ Template ใช้ id เฉพาะภายในอายุผลสแกนรอบเดียว ห้ามเก็บไปใช้ภายหลัง -7. **การมองเห็นช่องตามรุ่นของ Word**: collection `contentControls` ธรรมดาเห็น**เฉพาะ rich text CC** (docstring ของ `Body.contentControls`) — เครื่องมือ Template จึง feature-detect `WordApi 1.5` (`isSetSupported`) แล้วใช้ `Document.getContentControls()` ที่เห็นทุกชนิด (รวม plain text ซึ่งเป็นส่วนใหญ่ของ template ศาล — เคสจริง: T3 มี plain 26 + rich 1 สแกนเห็นเฉพาะ `detail1`) บน Word ต่ำกว่า 1.5 (เช่น Office 2019 = 1.3 ตาม manifest) มองเห็น/เขียนได้เฉพาะ rich text พร้อม warning แจ้งผู้ใช้ · **ห้ามยก MinVersion ของ manifest** เพื่อปลดข้อจำกัดนี้ (Office 2019 จะโหลด add-in ไม่ขึ้น — บทเรียน 2026-10-16) +7. **การมองเห็นช่องตามรุ่นของ Word**: collection `contentControls` ธรรมดาเห็น**เฉพาะ rich text CC** (docstring ของ `Body.contentControls`) — จึง feature-detect `WordApi 1.5` (`isSetSupported`) แล้วใช้ `Document.getContentControls()` ที่เห็นทุกชนิด (รวม plain text ซึ่งเป็นส่วนใหญ่ของ template ศาล — เคสจริง: T3 มี plain 26 + rich 1 เดิมเห็นเฉพาะ `detail1`) — helper `supportedContentControls`/`supportsAllContentControlTypes` อยู่ที่ `metadataService` ใช้ร่วม 2 ที่: เครื่องมือ Template (สแกน/เขียนค่า) และ **`readContentControlFields` ตอนสร้าง version** (fields ที่แนบ upload เดิมเคยเหลือแค่ rich ตัวเดียว — แก้ 2026-10-28 ให้เก็บ CC ทุกตัว) บน Word ต่ำกว่า 1.5 (เช่น Office 2019 = 1.3 ตาม manifest) มองเห็น/เขียนได้เฉพาะ rich text: เครื่องมือ Template มี warning แจ้งผู้ใช้ ส่วน fields ที่ upload ก็ rich-text-only เช่นกัน (ไม่มี UI แจ้ง — แก้ไม่ได้โดยไม่ยก MinVersion) · **ห้ามยก MinVersion ของ manifest** เพื่อปลดข้อจำกัดนี้ (Office 2019 จะโหลด add-in ไม่ขึ้น — บทเรียน 2026-10-16) --- diff --git a/lib/openapi.ts b/lib/openapi.ts index 4dc3b66..b68a269 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -37,13 +37,20 @@ export const openApiSpec = { { name: "documents", description: - "จัด version เอกสาร (docver): เอกสารเดียวต่อ docId แต่ละ " + - "version เก็บ .docx + .pdf ใน MinIO พร้อมผู้อัปโหลด — auth ด้วย access_token " + - "ของกลุ่มนี้: ขอที่ POST /api/documents/token ได้ 2 ทาง (x-api-key + body claims " + - "สำหรับระบบภายนอก หรือ body ticket_data+HmacSha256 สำหรับ add-in — PLAN-ADDIN-AUTH.md) " + - "แล้วแนบ Authorization: Bearer ทุก request — identity/template " + - "และ docId (= fid — fileId ของ e-Court, string) แกะจาก claims ของ token ทั้งหมด " + - "ไม่รับจาก URL/multipart form อีก", + "จัด version เอกสาร Word (.docx + .pdf) — ใช้งานจริงโดย Word add-in " + + '"e-Court Documents Versioning" และระบบภายนอกที่ต้องการดึงไฟล์\n\n' + + "**แนวคิด**\n" + + "- เอกสารหนึ่งฉบับถูกระบุด้วย `docId` ตัวเดียว ซึ่งก็คือ `fid` (fileId ของ e-Court)\n" + + "- ผู้ใช้กดบันทึกใน Word แต่ละครั้ง = เพิ่ม 1 version ที่เก็บไฟล์ .docx " + + "(+ สำเนา .pdf ถ้ามี) พร้อมชื่อผู้อัปโหลดและเวลา — ไฟล์เก่าของ version ก่อนหน้าไม่ถูกแก้\n\n" + + "**วิธีเรียกใช้ 3 ขั้น**\n" + + "1. ขอ `access_token` ที่ `POST /api/documents/token`\n" + + "2. แนบ `Authorization: Bearer ` กับทุก request ถัดไปของกลุ่มนี้\n" + + "3. เรียก endpoint ที่ต้องการ: อัปโหลด version ใหม่ / ดูประวัติ version / ขอลิงก์ดาวน์โหลด\n\n" + + "**ข้อควรรู้**\n" + + "- identity (userId/userName), templateId และ docId อ่านจาก claims ใน token ทั้งหมด — " + + "ไม่รับจาก URL หรือ form (ส่งมา = 400)\n" + + "- อัปโหลด version แรกของ docId ที่ยังไม่เคยมี = ระบบสร้างเอกสารให้อัตโนมัติ", }, { name: "Judge's Note", @@ -613,16 +620,18 @@ export const openApiSpec = { tags: ["documents"], summary: "ขอ access_token ของกลุ่ม documents (docver)", description: - "ออก access_token ใช้กับ endpoint อื่นของกลุ่ม documents เท่านั้น — token เป็น " + - "JWT HS256 ฝัง claims {userid, username, fid, template} อายุตาม env " + - "DOCVER_TOKEN_TTL_SECONDS (default 3600 วินาที) · identity (userId/userName), " + - "templateId และ docId (= fid) ตอนทำงานแกะจาก claims ของ token นี้ — " + - "fid จึงบังคับ · ทางเข้า 2 ทาง (header x-api-key เป็นตัวตั้ง): " + - "(1) ระบบภายนอก server-to-server — header x-api-key (env DOCVER_API_KEY) + body " + - "claims ตรง ๆ (2) add-in ใน Word — ไม่มี x-api-key + body {ticket_data, " + - "HmacSha256} ตามที่ e-Court stamp มากับไฟล์ — server ตรวจ HMAC-SHA256 ด้วย env " + - "ADDIN_TICKET_SECRET (ไม่ตั้ง = env SECRET ไม่ตั้ง = \"super_secret\") และ exp " + - "ของ ticket ก่อนออก token จาก claims ในตัว ticket เอง", + "ออก `access_token` (JWT HS256) ไว้เรียก endpoint อื่นของกลุ่ม documents — " + + "token ฝัง claims `{userid, username, fid, template}` อายุตาม env " + + "`DOCVER_TOKEN_TTL_SECONDS` (default 3600 วินาที)\n\n" + + "**ทางเข้า 2 แบบ** — เลือกตามว่าใครเป็นคนเรียก (ดูตัวอย่างใน Request body ได้ทั้งคู่):\n\n" + + "1. **ระบบภายนอก (server-to-server)** — แนบ header `x-api-key` (ค่าจาก env " + + "`DOCVER_API_KEY`) แล้วส่ง claims ตรง ๆ ใน body ตาม schema `TokenRequest`\n" + + "2. **add-in ใน Word** — ไม่ต้องมี `x-api-key` ส่ง body `{ticket_data, HmacSha256}` " + + "ตาม schema `TicketTokenRequest` (คู่ค่าที่ e-Court stamp ไว้ใน custom properties " + + "ของไฟล์ .docx) — server ตรวจลายเซ็น HMAC-SHA256 ด้วย env `ADDIN_TICKET_SECRET` " + + '(ไม่ตั้ง → env `SECRET` → `"super_secret"`) และเวลาหมดอายุ (`exp`) ของ ticket ' + + "ก่อน แล้วออก token จาก claims ที่อยู่ในตัว ticket เอง\n\n" + + "ได้ token แล้วแนบเป็น `Authorization: Bearer ` กับทุก request ถัดไป", security: [{ ApiKeyAuth: [] }], requestBody: { required: true, @@ -687,12 +696,15 @@ export const openApiSpec = { tags: ["documents"], summary: "อัปโหลด version ใหม่ของเอกสาร (docx + pdf)", description: - "เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) — สร้างเอกสาร " + - "อัตโนมัติเมื่อ docId ยังไม่เคยมี แล้วเก็บไฟล์ .docx (+ .pdf ถ้าแนบมา) ลง MinIO " + - "พร้อมผู้อัปโหลด — versionNumber ที่ server จ่ายคือค่าจริงเสมอ (client ที่เลขไม่ตรง " + - "ต้องแก้ XML part ของตัวเอง) · pdf เป็น optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มี " + - "สำเนา PDF (ดู hasPdf ใน response) · ผู้อัปโหลด (userId/userName) และ templateId " + - "แกะจาก Bearer token ไม่รับใน form (ส่ง field เหล่านั้นมา = 400)", + "บันทึกไฟล์ปัจจุบันของเอกสารเป็น **version ใหม่** — เอกสารถูกระบุด้วย `fid` " + + "ใน Bearer token ถ้า docId นี้ยังไม่เคยมีในระบบ จะสร้างเอกสารให้อัตโนมัติ (version แรก = 1)\n\n" + + "- ไฟล์เก็บใน MinIO แยก object ต่อ version พร้อมบันทึกผู้อัปโหลดจาก claims ใน token\n" + + "- `pdf` เป็น optional — ไม่แนบหรือแนบไฟล์เปล่า = version นี้ไม่มีสำเนา PDF " + + "(ดู `hasPdf` ใน response)\n" + + "- `versionNumber` ใน response คือเลขจริงที่ server จ่าย (ปลอดภัยแม้อัปโหลดพร้อมกัน " + + "หลาย client) — ถ้าต่างจากที่ client คาดไว้ ให้ใช้เลขจาก response เสมอ\n" + + "- ผู้อัปโหลด (userId/userName) และ templateId อ่านจาก token — " + + "**ห้ามส่ง field เหล่านี้ใน form (ส่งมา = 400)**", security: [{ DocverBearer: [] }], requestBody: { required: true, @@ -720,7 +732,9 @@ export const openApiSpec = { fields: { type: "string", description: - 'JSON string ของค่า Content Control เช่น {"blackno":"คดีหมายเลขดำที่"}', + 'JSON string ของค่า Content Control **ทุกตัว** ในเอกสาร รูปแบบ ' + + '`{ [tag]: ข้อความ }` (add-in อ่านมาตอนบันทึก version) ' + + 'เช่น {"blackno":"คดีหมายเลขดำที่","detail1":"…"}', }, message: { type: "string", @@ -762,9 +776,11 @@ export const openApiSpec = { tags: ["documents"], summary: "รายการ version ทั้งหมดของเอกสาร (ใหม่ → เก่า)", description: - "เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) — version ทุกตัว " + - "ของเอกสารนั้น พร้อมผู้อัปโหลด (userId/userName), เวลา, commit message, ค่า fields " + - "และ hasPdf — ใช้เขียน UI version history ของ add-in", + "version ทุกตัวของเอกสารที่ระบุด้วย `fid` ใน Bearer token เรียง **ใหม่ → เก่า** — " + + "แต่ละรายการมีผู้อัปโหลด, เวลา, commit message, ค่า Content Control ตอนอัปโหลด " + + "(`fields`) และว่ามีสำเนา PDF ไหม (`hasPdf`) — ใช้เขียน UI version history ของ add-in\n\n" + + "docId นี้ยังไม่เคยมี version เลย = 404 (สำหรับ add-in ถือเหมือน list ว่าง — " + + "กดบันทึกเพื่อสร้าง version 1 ได้ทันที)", security: [{ DocverBearer: [] }], responses: { 200: { @@ -797,10 +813,12 @@ export const openApiSpec = { tags: ["documents"], summary: "ขอ presigned URL ดาวน์โหลดไฟล์ของ version (docx/pdf)", description: - "เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) · คืน URL อายุสั้น " + - "(env DOCVER_LINK_TTL_SECONDS, default 900 วินาที) ให้เปิดในเบราว์เซอร์/Word ของ " + - "ผู้ใช้ — URL อ้าง endpoint MinIO ตรง ๆ จึงต้องเป็นโฮสต์ที่ผู้ใช้เข้าถึงได้ · ขอ pdf " + - "ใน version ที่ไม่มีสำเนา = 404", + "ขอ **presigned URL อายุสั้น** สำหรับดาวน์โหลดไฟล์ของ version หนึ่ง — URL ชี้ที่ " + + "MinIO ตรง ๆ ให้เปิดในเบราว์เซอร์/Word ของผู้ใช้\n\n" + + "- อายุ URL ตาม env `DOCVER_LINK_TTL_SECONDS` (default 900 วินาที) — " + + "หมดอายุแล้วขอใหม่ได้เรื่อย ๆ\n" + + "- ต้องเป็น version ของเอกสารเดียวกับ `fid` ใน token (โยน versionId ข้ามเอกสารไม่ได้)\n" + + "- ขอ `format=pdf` ใน version ที่ไม่มีสำเนา PDF = 404", security: [{ DocverBearer: [] }], parameters: [ { @@ -859,8 +877,9 @@ export const openApiSpec = { scheme: "bearer", bearerFormat: "JWT", description: - "access_token จาก POST /api/documents/token — แนบเป็น Authorization: Bearer " + - "กับ endpoint อื่นของกลุ่ม documents ทุกตัว", + "access_token จาก POST /api/documents/token — แนบเป็น " + + "`Authorization: Bearer ` กับ endpoint อื่นของกลุ่ม documents ทุกตัว\n" + + "(ตอนกรอกใน Swagger: ใส่เฉพาะค่า token — คำว่า Bearer ไม่ต้องพิมพ์เอง)", }, }, responses: { @@ -1162,7 +1181,8 @@ export const openApiSpec = { fields: { type: "object", nullable: true, - description: "ค่า Content Control ตอนอัปโหลด (ไม่ส่ง = null)", + description: + "ค่า Content Control ทุกตัวตอนอัปโหลด รูปแบบ `{ [tag]: ข้อความ }` (ไม่ส่ง = null)", additionalProperties: { type: "string" }, }, message: { type: "string", nullable: true, description: "commit message" }, diff --git a/tests/wordaddin/metadataService.test.ts b/tests/wordaddin/metadataService.test.ts new file mode 100644 index 0000000..080c3a5 --- /dev/null +++ b/tests/wordaddin/metadataService.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +// readContentControlFields (FR-11) — คุมว่าตอนสร้าง version อ่านค่า CC **ทุกชนิด**: +// host WordApi 1.5+ ต้องยิง Document.getContentControls() (เห็น plain text CC ซึ่งเป็น +// ส่วนใหญ่ของ template ศาล — เคสจริง T3 มี plain 26 + rich 1 แต่เดิมเหลือแค่ rich ตัว +// เดียวคือ detail1) ส่วน host ต่ำกว่า 1.3 ยังพังลง rich-text-only ตามเดิม (fallback +// ไม่ throw) + กติกา tag ว่างข้าม / tag ซ้ำเอาตัวแรกตามลำดับเอกสาร +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { readContentControlFields } from "@/wordaddin/services/metadataService"; + +interface FakeControl { + tag?: string; + text: string; +} + +/** stub Word + Office ระดับ global — คืน handle ให้ test ดึง collection ที่ถูกขอไปใช้ */ +function stubWord(controls: FakeControl[], options?: { getAllTypes?: boolean }): { + documentControls: { load: (p: string) => void }; + allTypesControls: { load: (p: string) => void }; +} { + const makeCollection = () => ({ + load: vi.fn(), + items: controls.map((control) => ({ tag: control.tag, text: control.text })), + }); + const documentControls = makeCollection(); + const allTypesControls = makeCollection(); + vi.stubGlobal("Office", { + context: { + requirements: { + isSetSupported: vi.fn(() => options?.getAllTypes ?? true), + }, + }, + }); + vi.stubGlobal("Word", { + run: async (batch: (context: unknown) => Promise) => + batch({ + document: { + contentControls: documentControls, + getContentControls: vi.fn(() => allTypesControls), + }, + sync: async () => undefined, + }), + }); + return { documentControls, allTypesControls }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("readContentControlFields", () => { + it("host 1.5+ ใช้ getContentControls() — เก็บค่า plain text CC ได้ครบทุกตัว", async () => { + const { documentControls, allTypesControls } = stubWord([ + { tag: "case_no", text: "ด1" }, // plain text — ตัวที่เดิมหลุดไปทั้งหมด + { tag: "court_name", text: "ศาลปกครอง" }, + { tag: "detail1", text: "ตุลาการเจ้าของสำนวนได้พิจารณา…" }, + ], { getAllTypes: true }); + + const fields = await readContentControlFields(); + + expect(fields).toEqual({ + case_no: "ด1", + court_name: "ศาลปกครอง", + detail1: "ตุลาการเจ้าของสำนวนได้พิจารณา…", + }); + expect(allTypesControls.load).toHaveBeenCalledWith("tag, text"); + expect(documentControls.load).not.toHaveBeenCalled(); + }); + + it("host ต่ำกว่า 1.5 (เช่น Office 2019 = 1.3) ยังใช้ collection rich-text-only ตามเดิม", async () => { + const { documentControls, allTypesControls } = stubWord([{ tag: "detail1", text: "rich ตัวเดียว" }], { + getAllTypes: false, + }); + + const fields = await readContentControlFields(); + + expect(fields).toEqual({ detail1: "rich ตัวเดียว" }); + expect(documentControls.load).toHaveBeenCalledWith("tag, text"); + expect(allTypesControls.load).not.toHaveBeenCalled(); + }); + + it("tag ว่างข้าม + tag ซ้ำเอาตัวแรกตามลำดับในเอกสาร + ไม่มี CC คืน {}", async () => { + stubWord([ + { tag: " ", text: "tag ว่าง" }, + { tag: "plain1", text: "ค่าแรก" }, + { tag: "plain1", text: "ค่าหลัง" }, + ]); + expect(await readContentControlFields()).toEqual({ plain1: "ค่าแรก" }); + }); + + it("ไม่มี content control เลย → คืน {} ไม่ error", async () => { + stubWord([]); + expect(await readContentControlFields()).toEqual({}); + }); +}); diff --git a/wordaddin/services/metadataService.ts b/wordaddin/services/metadataService.ts index 68a8693..e25c5db 100644 --- a/wordaddin/services/metadataService.ts +++ b/wordaddin/services/metadataService.ts @@ -1,10 +1,11 @@ -/* global Word, console */ +/* global Word, Office, console */ /** * Metadata ในไฟล์ .docx ที่ add-in อ่าน/เขียน (spec ส่วน 8): * - Custom Document Properties = primary flag ตรวจจับ tracked document * - Custom XML Part (ns `urn:myaddin:versioning`) = secondary เท่านั้น — backend คือ source of truth * - readContentControlFields (FR-11) อ่านค่า content controls เป็น map tag→text + * (ทุกชนิดบน host WordApi 1.5+ — ด้วย supportedContentControls ด้านล่าง) * พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\metadataService.ts * * กติกา: ทุก Word.run ต้องจบด้วย context.sync(); XML part ฝั่งเขียน **ห้าม throw** @@ -231,14 +232,38 @@ export async function writeVersionHistoryToXml(entries: XmlVersionEntry[]): Prom // Content controls → fields (FR-11) // --------------------------------------------------------------------------- +/** host นี้มองเห็น CC ทุกชนิด (รวม plain text) ได้ไหม — WordApi 1.5+ เท่านั้น + * (เรียกนอก Word เช่น browser ธรรมดา → catch คืน false เสมอ ตามแบบ canCloseDocument) + * ย้ายมาจาก templateService (2026-10-28) ให้ readContentControlFields ใช้ร่วม — + * collection `contentControls` ธรรมดามองเห็นเฉพาะ rich text CC ทำให้ fields ตอน + * สร้าง version เหลือแค่ rich ตัวเดียว (เคสจริง T3: plain 26 + rich 1 = เห็นแค่ detail1) */ +export function supportsAllContentControlTypes(): boolean { + try { + return Office.context.requirements.isSetSupported("WordApi", "1.5"); + } catch { + return false; + } +} + +/** collection ของ CC ที่ host รองรับสูงสุด — 1.5+ ใช้ getContentControls() (ทุกชนิด) + * ต่ำกว่านั้นต้องยอมรับ collection rich-text-only */ +export function supportedContentControls(context: Word.RequestContext): Word.ContentControlCollection { + return supportsAllContentControlTypes() + ? context.document.getContentControls() + : context.document.contentControls; +} + /** * อ่าน content controls ทั้งเอกสารเป็น map `{ [tag]: text }` สำหรับแนบกับ upload version + * - ใช้ supportedContentControls → host 1.5+ เห็น CC ทุกชนิด (รวม plain text ซึ่งเป็น + * ส่วนใหญ่ของ template ศาล) ส่วน host ต่ำกว่า (Office 2019 = 1.3) เห็นเฉพาะ rich text + * เท่าเดิม — ห้ามยก MinVersion ของ manifest เพื่อปลดข้อจำกัดนี้ (บทเรียน 2026-10-16) * - ไม่มี content controls / tag ว่าง → ข้าม (คืน {} ไม่ error) * - tag ซ้ำ → เอาตัวแรกตามลำดับในเอกสาร */ export async function readContentControlFields(): Promise> { return Word.run(async (context) => { - const controls = context.document.contentControls; + const controls = supportedContentControls(context); controls.load("tag, text"); await context.sync(); diff --git a/wordaddin/services/templateService.ts b/wordaddin/services/templateService.ts index 85c5c99..9336d88 100644 --- a/wordaddin/services/templateService.ts +++ b/wordaddin/services/templateService.ts @@ -1,4 +1,4 @@ -/* global Word, Office */ +/* global Word */ /** * แท็บ "เครื่องมือ Template" (แท็บที่สองของ taskpane) — เครื่องมือจัดการ Content Controls @@ -13,7 +13,8 @@ * Body.contentControls: "rich text content control objects") — template ของศาลส่วนใหญ่เป็น * plain text CC จึงต้องใช้ `Document.getContentControls()` (WordApi 1.5, ครอบทุกชนิดที่รองรับ) * ผ่าน feature-detect ตอน runtime แทนการยก MinVersion ของ manifest (Office 2019 = 1.3 - * ต้องยังโหลดได้ — เห็นเฉพาะ rich text + UI แจ้งเตือน) + * ต้องยังโหลดได้ — เห็นเฉพาะ rich text + UI แจ้งเตือน) — helper supportedContentControls + * อยู่ที่ metadataService (แชร์กับ readContentControlFields ของฝั่ง versioning ด้วย) * * กติกาตาม repo: ห้ามมี Office/Word access ระดับ top-level (module นี้ถูก evaluate ใน * browser ธรรมดาตอนโหมด `?addindemo` ที่ไม่มี Office global — top-level access จะ throw ก่อน UI ทัน render) · operation ที่เขียนเอกสารต้อง suspend/resume @@ -23,6 +24,7 @@ import { autoSaveService } from "./autoSaveService"; import { getDetailedErrorMessage } from "./errors"; +import { supportsAllContentControlTypes, supportedContentControls } from "./metadataService"; /** ผลสแกนหนึ่งช่อง — id ของ CC ไม่เสถียรข้าม session/coauthor (ตาม docstring ของ Office.js * เอง: "The ID differs across sessions and coauthors") จึงใช้ได้เฉพาะอายุผลสแกนรอบเดียว ห้ามเก็บไปใช้ภายหลัง */ @@ -50,23 +52,10 @@ function toScannedField(control: Word.ContentControl): ScannedField { }; } -/** host นี้มองเห็น CC ทุกชนิด (รวม plain text) ได้ไหม — WordApi 1.5+ เท่านั้น - * (เรียกนอก Word เช่น browser ธรรมดา → catch คืน false เสมอ ตามแบบ canCloseDocument) */ -export function supportsAllContentControlTypes(): boolean { - try { - return Office.context.requirements.isSetSupported("WordApi", "1.5"); - } catch { - return false; - } -} - -/** collection ของ CC ที่ host รองรับสูงสุด — 1.5+ ใช้ getContentControls() (ทุกชนิด) - * ต่ำกว่านั้นต้องยอมรับ collection rich-text-only */ -function supportedContentControls(context: Word.RequestContext): Word.ContentControlCollection { - return supportsAllContentControlTypes() - ? context.document.getContentControls() - : context.document.contentControls; -} +/** host นี้มองเห็น CC ทุกชนิด (รวม plain text) ได้ไหม — ย้ายไปอยู่ที่ metadataService + * (2026-10-28) ให้ readContentControlFields ของฝั่ง versioning ใช้ร่วม แล้ว re-export + * ไว้ที่นี่เพื่อให้ TemplateToolsPanel เรียกจาก templateService ได้ตามเดิม */ +export { supportsAllContentControlTypes } from "./metadataService"; /** สแกน Content Controls ทั้งเอกสาร (1 sync) — เรียงตามลำดับในเอกสาร · ไม่มีช่อง = คืน [] ไม่ error * อ่านอย่างเดียวจึงไม่ต้อง suspend auto-save */