feat: enhance document versioning and metadata handling in Word add-in
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m21s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m21s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
- Updated API documentation for document versioning to clarify usage and requirements. - Improved metadata service to support all content control types in Word API 1.5+. - Added tests for reading content control fields to ensure compatibility with various Word versions. - Refactored template service to utilize shared metadata functions for better maintainability.
This commit is contained in:
parent
677de2fc29
commit
3726cb742f
7 changed files with 187 additions and 58 deletions
|
|
@ -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 ใช้ร่วม)
|
||||
|
|
|
|||
|
|
@ -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` แล้ว) |
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <access_token>` กับทุก 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 <token>` กับทุก 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 <token>` กับ 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" },
|
||||
|
|
|
|||
95
tests/wordaddin/metadataService.test.ts
Normal file
95
tests/wordaddin/metadataService.test.ts
Normal file
|
|
@ -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<unknown>) =>
|
||||
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({});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Record<string, string>> {
|
||||
return Word.run(async (context) => {
|
||||
const controls = context.document.contentControls;
|
||||
const controls = supportedContentControls(context);
|
||||
controls.load("tag, text");
|
||||
await context.sync();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue