implement phase 4
This commit is contained in:
parent
25c2ce53cd
commit
f4b1c3e14b
27 changed files with 6929 additions and 48 deletions
|
|
@ -1,7 +1,7 @@
|
|||
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
|
||||
|
||||
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
|
||||
> สถานะ: Phase 0–3 เสร็จ (scaffold + infra + backend core + backend template & generation) — ต่อไป: phase 4–5 (add-in) หรือ phase 6 (web) ทำขนานกันได้
|
||||
> สถานะ: Phase 0–4 เสร็จ (scaffold + infra + backend ครบ + add-in services layer) — ต่อไป: phase 5 (add-in UI) หรือ phase 6 (web) ทำขนานกันได้
|
||||
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
|
||||
|
||||
---
|
||||
|
|
@ -293,31 +293,48 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
|
|||
**เป้า:** logic ทั้งหมดที่ UI จะเรียกใช้ + unit test — ตรง spec ส่วน 11 (frontend ข้อ 2, 3, 5, 6)
|
||||
|
||||
### 4.1 Auth (`authService.ts`, `auth-callback.html`)
|
||||
- [ ] สร้าง `code_verifier` (random 43–128 chars) + `code_challenge` = base64url(SHA256(verifier))
|
||||
- [ ] สร้าง authorization URL: Keycloak endpoint + client_id + redirect_uri (`auth-callback.html`) + response_type=code + scope=openid + state + code_challenge (S256)
|
||||
- [ ] เปิด `Office.context.ui.displayDialogAsync(url, {height: 60, width: 30})` + `Dialog.addEventHandler(Office.EventType.DialogMessageReceived)` รับ message กลับ
|
||||
- [ ] `auth-callback.html` — parse `code`/`state` จาก URL, `Office.context.ui.messageParent(JSON.stringify({code, state}))` แล้ว `window.close()`
|
||||
- [ ] แลก token ที่ Keycloak token endpoint ด้วย verifier (public client ไม่พึ่ง backend)
|
||||
- [ ] เก็บ `access_token`/`refresh_token` **ใน JS variable ของ task pane session**; expose `isTokenValid()`, `getAccessToken()`
|
||||
- [ ] Silent refresh ก่อนหมดอายุ + ถ้า refresh token ตาย/ถูก revoke → คืนสถานะ "ต้อง login ใหม่" แบบ graceful (ไม่ error ค้าง)
|
||||
- [ ] ป้องกัน CSRF ด้วยการเทียบ `state`
|
||||
- [x] สร้าง `code_verifier` (random 43–128 chars) + `code_challenge` = base64url(SHA256(verifier))
|
||||
- [x] สร้าง authorization URL: Keycloak endpoint + client_id + redirect_uri (`auth-callback.html`) + response_type=code + scope=openid + state + code_challenge (S256)
|
||||
- [x] เปิด `Office.context.ui.displayDialogAsync(url, {height: 60, width: 30})` + `Dialog.addEventHandler(Office.EventType.DialogMessageReceived)` รับ message กลับ
|
||||
- [x] `auth-callback.html` — parse `code`/`state` จาก URL, `Office.context.ui.messageParent(JSON.stringify({code, state}))` แล้ว `window.close()`
|
||||
- [x] แลก token ที่ Keycloak token endpoint ด้วย verifier (public client ไม่พึ่ง backend)
|
||||
- [x] เก็บ `access_token`/`refresh_token` **ใน JS variable ของ task pane session**; expose `isTokenValid()`, `getAccessToken()`
|
||||
- [x] Silent refresh ก่อนหมดอายุ + ถ้า refresh token ตาย/ถูก revoke → คืนสถานะ "ต้อง login ใหม่" แบบ graceful (ไม่ error ค้าง)
|
||||
- [x] ป้องกัน CSRF ด้วยการเทียบ `state`
|
||||
|
||||
### 4.2 Metadata (`metadataService.ts`)
|
||||
- [ ] อ่าน Custom Properties: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`
|
||||
- [ ] เขียน/stamp properties (ใช้ property `add()` ของใหม่ทับ — ไม่มีปัญหาแบบ XML part)
|
||||
- [ ] XML part ฝั่งอ่าน: หา part ที่ namespace `urn:myaddin:versioning` → parse เป็น list
|
||||
- [ ] XML part ฝั่งเขียน: **delete ของเดิมทั้งหมดก่อนค่อย `add()` ใหม่** + **catch error แบบไม่ throw** (known bug บน Mac — เขียนไม่ได้ก็ปล่อยผ่านเพราะ backend คือ source of truth)
|
||||
- [ ] Constants: `ADDIN_SIGNATURE_GUID`
|
||||
- [ ] **(FR-11) `readContentControlFields()`** — `Word.run` → `context.document.contentControls` → `load("tag, text")` → คืน `{ [tag]: text }`; เอกสารที่ไม่มี content controls → คืน `{}` ไม่ error
|
||||
- [x] อ่าน Custom Properties: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`
|
||||
- [x] เขียน/stamp properties (ใช้ property `add()` ของใหม่ทับ — ไม่มีปัญหาแบบ XML part)
|
||||
- [x] XML part ฝั่งอ่าน: หา part ที่ namespace `urn:myaddin:versioning` → parse เป็น list
|
||||
- [x] XML part ฝั่งเขียน: **delete ของเดิมทั้งหมดก่อนค่อย `add()` ใหม่** + **catch error แบบไม่ throw** (known bug บน Mac — เขียนไม่ได้ก็ปล่อยผ่านเพราะ backend คือ source of truth)
|
||||
- [x] Constants: `ADDIN_SIGNATURE_GUID`
|
||||
- [x] **(FR-11) `readContentControlFields()`** — `Word.run` → `context.document.contentControls` → `load("tag, text")` → คืน `{ [tag]: text }`; เอกสารที่ไม่มี content controls → คืน `{}` ไม่ error
|
||||
|
||||
### 4.3 File + API (`fileService.ts`, `apiClient.ts`, `versioningService.ts`)
|
||||
- [ ] `fileService` — `getFileAsync(Office.FileType.Compressed)` → อ่าน slice ทีละชิ้น → ประกอบเป็น Blob → **เรียก `closeAsync` เสมอ** (จำกัดจำนวน file object ที่เปิดพร้อมกันได้)
|
||||
- [ ] `apiClient` — wrapper fetch ที่แนบ `Authorization: Bearer <token>` ทุก request; โดน 401 → สั่ง refresh token แล้ว retry ครั้งเดียว; ยัง 401 → ส่งสัญญาณให้ UI กลับไปหน้า sign-in
|
||||
- [ ] `versioningService` — `uploadVersion(documentId, blob, clientVersionId, fields)`, `getVersions(documentId)`, `getDownloadUrl(...)`/download — **fields = map จาก `readContentControlFields()`**
|
||||
- [ ] `models/versionRecord.ts` — type ตรงกับ response ของ backend (รวม snapshot fields ต่อ version)
|
||||
- [x] `fileService` — `getFileAsync(Office.FileType.Compressed)` → อ่าน slice ทีละชิ้น → ประกอบเป็น Blob → **เรียก `closeAsync` เสมอ** (จำกัดจำนวน file object ที่เปิดพร้อมกันได้)
|
||||
- [x] `apiClient` — wrapper fetch ที่แนบ `Authorization: Bearer <token>` ทุก request; โดน 401 → สั่ง refresh token แล้ว retry ครั้งเดียว; ยัง 401 → ส่งสัญญาณให้ UI กลับไปหน้า sign-in
|
||||
- [x] `versioningService` — `uploadVersion(documentId, blob, clientVersionId, fields)`, `getVersions(documentId)`, `getDownloadUrl(...)`/download — **fields = map จาก `readContentControlFields()`**
|
||||
- [x] `models/versionRecord.ts` — type ตรงกับ response ของ backend (รวม snapshot fields ต่อ version)
|
||||
|
||||
### 4.4 Manifest
|
||||
- [ ] `manifest.xml` ประกาศ requirement sets ให้ครอบ `WordApi 1.5` (customProperties, customXmlParts, `document.save()`) + `DialogAPI`
|
||||
- [x] `manifest.xml` ประกาศ requirement sets ให้ครอบ `WordApi 1.5` (customProperties, customXmlParts, `document.save()`) + `DialogAPI`
|
||||
|
||||
หมายเหตุจากการทำจริง (2026-08-19):
|
||||
- **`customXmlParts` อยู่บน `context.document` ไม่ใช่ `context`** (ตาม @types/office-js — WordApi 1.4) — ใช้ `context.document.customXmlParts.getByNamespace(...)` / `.add(...)`
|
||||
- **Config inject ผ่าน webpack DefinePlugin** (`KEYCLOAK_AUTHORITY` / `KEYCLOAK_CLIENT_ID` / `API_BASE_URL` จาก env ตอน build, default dev = Keycloak 8080 / backend 5075) — อ่านที่ `src/config.ts`; `redirectUri` derive จาก `window.location.origin + "/auth-callback.html"` ให้ตรง realm-export เสมอ
|
||||
- `auth-callback.html` ต้องอยู่ **root ของ dist** ตรง redirect URI — เพิ่ม entry `authCallback` + HtmlWebpackPlugin แยกใน webpack.config.js (template `src/taskpane/auth-callback.html` + logic ใน `auth-callback.ts`, ไม่ใช่ React)
|
||||
- **Jest ใช้ babel-jest** (คอมไพล์ด้วย babel.config.json เดิม) ไม่ใช่ ts-jest — ts-jest@29 peerOptional ไม่รับ @babel/core 8 ของ scaffold; ผลข้างเคียง: test ไม่ type-check ตอนรัน (type ตรวจที่ `npm run build` ผ่าน ts-loader ซึ่งไล่ทั้ง program รวม *.test.ts แล้ว)
|
||||
- jsdom env ไม่มี `Response`/`fetch`/`crypto.subtle` และ `crypto` เป็น getter-only (assign ตรงๆ เงียบ) → `src/test/setup.ts` ต้อง `Object.defineProperty` แล้วดึง webcrypto จาก node; mock HTTP จึงเป็น duck-typing object ไม่ใช่ `Response` จริง (ดู `src/test/httpMock.ts`)
|
||||
- test ที่รอ dialog ต้อง **poll จน `displayDialogAsync` ถูกเรียก** แทน setTimeout(0) — บนเครื่องโหลด `crypto.subtle.digest` อาจยังไม่จบภายใน macrotask เดียว (มิฉะนั้น message หลุดก่อน handler ลงทะเบียน)
|
||||
- **tsconfig เพิ่ม `types` จำกัด** (office-js, office-runtime, react, react-dom, jest, node) — ไม่อย่างนั้น `@types/babel__core` ที่ jest/babel-jest ดึงเข้ามาชนกับ @babel/types v8 จน tsc พัง
|
||||
- **eslint: สร้าง `eslint.config.mjs` ของโปรเจกต์** (office-addin-lint จะใช้แทน config ของมันถ้ามีไฟล์นี้ใน root) เพราะ rule `no-navigational-load` ของ plugin 4.0.10 (a) crash กับ `context.load(obj)` แบบ 1 อาร์กิวเมนต์ (b) false positive กับ Word properties เช่น `tag` — ปิด rule นี้ + ปิด `no-undef`/`no-redeclare` สำหรับ .ts/.tsx (tsc ตรวจแทนแม่นกว่า)
|
||||
- manifest: element `Requirements` วางระหว่าง `</Hosts>` กับ `<DefaultSettings>` — `npm run validate` ผ่าน และ platform analysis ยังครบ (Word desktop/web/iPad ทุกรุ่นรองรับ WordApi 1.5 + DialogAPI 1.1)
|
||||
- `readTrackedProperties` ใช้ `getItemOrNullObject(...).load("value")` แล้วอ่าน `isNullObject` หลัง sync (null-object proxy ต้องถูก load อย่างน้อย 1 property ค่า isNullObject จึงแม่น)
|
||||
- Services ยังไม่ถูก import เข้า taskpane entry (เป็นงาน Phase 5) — ครั้งแรกที่จะรันจริงใน Word คือตอน wire UI
|
||||
|
||||
**DoD:** `npm run build` ผ่าน + unit tests ผ่าน (Jest: mock `Word.run`, `Office.context.ui`, `fetch`, Keycloak token response) — ครอบ PKCE flow, delete-then-add XML part, apiClient retry-on-401, fileService ประกอบ slice, **readContentControlFields แปลง contentControls → map ถูกต้อง (รวมกรณี control ซ้ำ tag → เอาตัวแรก + tag ว่าง → ข้าม)**
|
||||
|
||||
> ✅ ตรวจผ่านจริง (2026-08-19): `npm run build` compiled successfully (0 errors), `npm test` 40/40 ผ่านทั้ง 4 ชุด (authService 14, metadataService 14, apiClient 8, fileService 4 รวมกรณี slice พังกลางทาง→ยัง closeAsync, closeAsync พัง→ยังได้ blob), `npm run lint` ผ่าน, `npm run validate` (manifest) ผ่าน
|
||||
|
||||
**DoD:** `npm run build` ผ่าน + unit tests ผ่าน (Jest: mock `Word.run`, `Office.context.ui`, `fetch`, Keycloak token response) — ครอบ PKCE flow, delete-then-add XML part, apiClient retry-on-401, fileService ประกอบ slice, **readContentControlFields แปลง contentControls → map ถูกต้อง (รวมกรณี control ซ้ำ tag → เอาตัวแรก + tag ว่าง → ข้าม)**
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue