implement phase 5 และ fix bugs
This commit is contained in:
parent
f4b1c3e14b
commit
1bb850c6b8
40 changed files with 1507 additions and 429 deletions
|
|
@ -15,7 +15,7 @@ MINIO_ROOT_USER=wordversioning-minio
|
|||
MINIO_ROOT_PASSWORD=dev-local-pass
|
||||
|
||||
# --- Keycloak bootstrap admin (service: keycloak) ---
|
||||
# ใช้ล็อกอิน admin console ครั้งแรกที่ http://localhost:8080/admin/
|
||||
# ใช้ล็อกอิน admin console ครั้งแรกที่ https://localhost:8443/admin/ (cert dev ต้อง install ก่อน — ดู README)
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD=dev-local-pass
|
||||
|
||||
|
|
|
|||
18
CLAUDE.md
18
CLAUDE.md
|
|
@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
## โปรเจกต์นี้คืออะไร
|
||||
|
||||
ระบบ **Word Versioning**: จัดการ version history ของไฟล์ .docx — สร้างเอกสารจาก template ผ่านเว็บ, ตรวจจับ tracked document ใน Word, บันทึก version ผ่านปุ่ม Save & Version และ sync ค่า fields กลับ DB ประกอบด้วย 3 ส่วน:
|
||||
ระบบ **Word Versioning** (ชื่อที่แสดงใน UI: **e-Court Documents Versioning**): จัดการ version history ของไฟล์ .docx — สร้างเอกสารจาก template ผ่านเว็บ, ตรวจจับ tracked document ใน Word, บันทึก version ผ่านปุ่ม Save & Version และ sync ค่า fields กลับ DB ประกอบด้วย 3 ส่วน:
|
||||
|
||||
| ส่วน | Stack | หน้าที่ |
|
||||
|---|---|---|
|
||||
|
|
@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`)
|
||||
|
||||
**สถานะปัจจุบัน: Phase 0–3 เสร็จ** (scaffold + infra + backend core FR-5–8 + backend template & generation FR-9–11) — งานต่อคือ implement phase 4–8 ในแผน (4–5 add-in กับ 6 web ทำขนานกันได้)
|
||||
**สถานะปัจจุบัน: Phase 0–5 เสร็จ** (backend ครบ + add-in ครบทั้ง services และ task pane UI — sideload ทดสอบจริงบน Word for Mac แล้ว แก้ปัญหาตกมุม 4 รอบ) — งานต่อคือ phase 6 (web portal), 7 (tests ครบชุด), 8 (ไล่ acceptance criteria) ตามแผน
|
||||
|
||||
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
|
||||
|
||||
|
|
@ -35,6 +35,9 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ
|
|||
8. **Ports:** add-in = `https://localhost:3000`, web = `http://localhost:5173` (ห้ามชนกัน), backend CORS ต้องอนุญาตทั้งคู่
|
||||
9. **Config (DB/MinIO/Keycloak) แยกเป็น env/appsettings เท่านั้น** — ห้าม hardcode; `userId` เอาจาก JWT claims เท่านั้น ห้ามรับจาก client
|
||||
10. **ทุก Office.js call ต้อง catch** — ห้ามปล่อย unhandled promise rejection
|
||||
11. **Dev: ทุก service ที่ task pane (หน้า https) เรียกต้องเป็น HTTPS ด้วย cert ของ `office-addin-dev-certs`** — Keycloak = `https://localhost:8443` (Office Dialog API ปฏิเสธ http:// โดยสมบูรณ์), backend = `https://localhost:7232` + `http://localhost:5075` (WKWebView บล็อก mixed content → "Load failed") — รัน `npx office-addin-dev-certs install` ก่อน `docker compose up`
|
||||
12. **ดาวน์โหลดไฟล์จาก add-in ใช้ presigned URL + `Office.context.ui.openBrowserWindow` เท่านั้น** — webview ของ Word ทิ้ง blob download (`a[download]`) แบบเงียบๆ ไฟล์ไม่ถูกเขียนที่ไหนเลย; และ**ห้าม `cp` ทับ manifest ใน wef folder** (`npm start` ใช้ symlink — ทับแล้วครั้งหน้า EEXIST, ให้แก้ที่ `addin/manifest.xml` ต้นทาง)
|
||||
13. **`slice.data` จาก `getSliceAsync` มีหลายรูปแบบตาม host** (ArrayBuffer / view / base64 string / `number[]`) — ต้องผ่าน `fileService.sliceDataToBytes()` เสมอ ห้าม assume รูปเดียว
|
||||
|
||||
## สถาปัตยกรรม — flow หลักที่ต้องเข้าใจ
|
||||
|
||||
|
|
@ -44,16 +47,17 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ
|
|||
|
||||
**Field sync policy (FR-11):** field ขาด (ผู้ใช้ลบ control) → เก็บค่าเดิม + mark missing **ไม่ fail request**; ส่งมาเกินนิยาม → ignore
|
||||
|
||||
**MinIO:** bucket `docx-versions` (snapshot ต่อ version, key `{documentId}/v{N:D4}_{clientVersionId}.docx`) + `docx-templates` (ไฟล์ template) — private ทั้งคู่, เข้าถึงผ่าน backend proxy stream เท่านั้น
|
||||
**MinIO:** bucket `docx-versions` (snapshot ต่อ version, key `{documentId}/v{N:D4}_{clientVersionId}.docx`) + `docx-templates` (ไฟล์ template) — private ทั้งคู่, เข้าถึงผ่าน backend เท่านั้น (proxy stream หรือ presigned URL อายุสั้นจาก `download-link` — ซึ่ง add-in ใช้เปิดในเบราว์เซอร์ของเครื่อง)
|
||||
|
||||
**Template:** template.docx author ด้วย Word (ใส่ content controls) → upload → backend parse SDT tags → admin ตั้ง label/type/required → delete = deactivate (soft) เพราะ document เก่ายังอ้างอยู่
|
||||
|
||||
## คำสั่ง (ใช้ได้หลัง scaffold ใน Phase 0)
|
||||
|
||||
```bash
|
||||
docker compose up -d # infra: PostgreSQL + MinIO + Keycloak
|
||||
docker compose up -d # infra: PostgreSQL + MinIO + Keycloak(https:8443)
|
||||
python3 scripts/dev-token.py # token PKCE จริงสำหรับทดสอบ backend/Swagger (admin-cli ไม่มี aud ใช้ไม่ได้)
|
||||
|
||||
# backend
|
||||
# backend (http://localhost:5075 + https://localhost:7232 — dev เปิด https อัตโนมัติด้วย dev cert)
|
||||
cd backend/WordVersioning.Api
|
||||
dotnet run # API + swagger
|
||||
dotnet ef migrations add <Name>
|
||||
|
|
@ -62,11 +66,11 @@ dotnet test --filter "FullyQualifiedName~GenerateTests" # รัน test เ
|
|||
|
||||
# add-in (https://localhost:3000)
|
||||
cd addin
|
||||
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว — ทำก่อน docker compose up
|
||||
npm run build # webpack build
|
||||
npm start # dev server — sideload ผ่าน manifest
|
||||
npm start # dev server — sideload ผ่าน manifest (symlink)
|
||||
npm test
|
||||
npx jest src/services/metadataService.test.ts # รัน test เดี่ยว
|
||||
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว
|
||||
|
||||
# web portal (http://localhost:5173 — ห้ามใช้ 3000)
|
||||
cd web
|
||||
|
|
|
|||
72
README.md
72
README.md
|
|
@ -1,4 +1,4 @@
|
|||
# Word Versioning Add-in
|
||||
# e-Court Documents Versioning (Word Add-in)
|
||||
|
||||
ระบบจัดการ version history ของเอกสาร Word — สร้างเอกสารจาก template ผ่านเว็บ ติดตามทุกการแก้ไขผ่าน add-in ใน Word และ sync ข้อมูลสำคัญกลับฐานข้อมูลอัตโนมัติ
|
||||
|
||||
|
|
@ -35,11 +35,11 @@
|
|||
| 2 | Backend core — version API (FR-5–7) + JWT auth (FR-8) | ✅ เสร็จ |
|
||||
| 3 | Template system + docx generation + field sync (FR-9–11) | ✅ เสร็จ |
|
||||
| 4 | Add-in services layer — auth (PKCE + Dialog API), metadata, file/API, field extraction (FR-8, FR-11) | ✅ เสร็จ |
|
||||
| 5 | Task pane UI — sign-in, Start Versioning, Save & Version, history (FR-1–4) | ⏳ รอ implement |
|
||||
| 5 | Task pane UI — sign-in, Start Versioning, Save & Version, history (FR-1–4) | ✅ เสร็จ — ทดสอบจริงบน Word for Mac แล้ว (login/tracking/Save & Version ผ่าน, แก้ปัญหาที่เจอ 4 ประเด็น — ดู Troubleshooting) เหลือยืนยัน download + AC เต็มชุด |
|
||||
| 6 | Web Portal UI (FR-9–10) | ⏳ รอ implement |
|
||||
| 7–8 | Tests ครบชุด + เอกสารฉบับเต็ม | ⏳ รอ implement |
|
||||
|
||||
**ใช้ได้แล้วตอนนี้:** infra ทั้งชุด + backend API ครบทั้ง version API (upload / history / download) และ template system (upload template พร้อม auto-detect fields, generate เอกสารเป็น v1, field sync ตอนสร้าง version) — ลองเล่นได้ผ่าน Swagger หรือ curl (ตัวอย่างด้านล่าง) · ฝั่ง add-in มี services layer พร้อมใช้ครบพร้อม unit tests 40 ตัว (รอ Phase 5 เอาไปต่อ UI)
|
||||
**ใช้ได้แล้วตอนนี้:** infra ทั้งชุด + backend API ครบทั้ง version API (upload / history / download) และ template system (upload template พร้อม auto-detect fields, generate เอกสารเป็น v1, field sync ตอนสร้าง version) — ลองเล่นได้ผ่าน Swagger หรือ curl (ตัวอย่างด้านล่าง) · ฝั่ง add-in ครบทั้ง services layer และ task pane UI พร้อม unit tests 45 ตัว
|
||||
|
||||
## เอกสาร
|
||||
|
||||
|
|
@ -58,6 +58,11 @@
|
|||
dotnet tool install --global dotnet-ef --version 8.0.11
|
||||
```
|
||||
- Node.js LTS (add-in และ web)
|
||||
- **Dev cert สำหรับ localhost** (ต้องทำ**ก่อน** `docker compose up` — Keycloak mount cert จากที่นี่):
|
||||
```bash
|
||||
cd addin && npx office-addin-dev-certs install
|
||||
```
|
||||
ทำครั้งเดียวต่อเครื่อง: สร้าง CA + cert ที่ trust ไว้ใน keychain ใช้ทั้ง add-in dev server (https://localhost:3000) และ Keycloak (https://localhost:8443)
|
||||
|
||||
### 1) รัน infrastructure (PostgreSQL + MinIO + Keycloak)
|
||||
|
||||
|
|
@ -70,10 +75,12 @@ docker compose up -d
|
|||
|
||||
| บริการ | URL | เข้าด้วย |
|
||||
|---|---|---|
|
||||
| Keycloak admin console | http://localhost:8080/admin/ | `KC_BOOTSTRAP_ADMIN_USERNAME` / `KC_BOOTSTRAP_ADMIN_PASSWORD` จาก `.env` |
|
||||
| Keycloak admin console | https://localhost:8443/admin/ | `KC_BOOTSTRAP_ADMIN_USERNAME` / `KC_BOOTSTRAP_ADMIN_PASSWORD` จาก `.env` |
|
||||
| MinIO console | http://localhost:9001 | `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` จาก `.env` |
|
||||
| PostgreSQL | `localhost:5432` | `POSTGRES_USER` / `POSTGRES_PASSWORD` จาก `.env`, ฐานข้อมูล `wordversioning` |
|
||||
|
||||
> **ทำไม Keycloak ต้องเป็น HTTPS:** Office Dialog API ที่ add-in ใช้เปิดหน้า login **ปฏิเสธ http:// โดยสมบูรณ์** (error: "The HTTP protocol is not supported. Use HTTPS instead") — dev จึงให้ Keycloak เสิร์ฟ HTTPS ที่ port 8443 ด้วย cert ของ `office-addin-dev-certs` (CA ถูก trust ในเครื่องแล้ว Word/webview จึงเชื่อ) · ตัว issuer ใน token จะเป็น `https://localhost:8443/realms/word-versioning` ซึ่ง backend ตั้ง `Authentication:Authority` ตรงกัน
|
||||
|
||||
Keycloak จะ import realm `word-versioning` อัตโนมัติ (clients 3 ตัว + test user) จาก [keycloak/realm-export.json](keycloak/realm-export.json) — ไฟล์นี้คือ source of truth ของ realm ฝั่ง dev: แก้ realm ให้แก้ที่ไฟล์นี้แล้วรัน `docker compose rm -sf keycloak && docker compose up -d` เพื่อ import ใหม่ (การแก้ผ่าน admin console จะหายเมื่อ recreate container)
|
||||
|
||||
**Test user สำหรับล็อกอิน:** `testuser` / `Test1234!` (เปลี่ยนได้ใน realm-export.json)
|
||||
|
|
@ -85,9 +92,11 @@ MinIO จะมี bucket `docx-versions` และ `docx-templates` (private
|
|||
```bash
|
||||
cd backend/WordVersioning.Api
|
||||
dotnet ef database update # ครั้งแรก / เมื่อมี migration ใหม่
|
||||
dotnet run # Swagger ที่ http://localhost:5075/swagger
|
||||
dotnet run # รันทั้ง http://localhost:5075 และ https://localhost:7232 (Swagger ใช้ https ก็ได้)
|
||||
```
|
||||
|
||||
> **ทำไม dev ต้องมี endpoint HTTPS ด้วย:** task pane ของ add-in เป็นหน้า `https://localhost:3000` และ **webview ของ Word (WKWebView บน Mac) บล็อก fetch จากหน้า https ไป http แบบเงียบๆ** — error ที่เห็นคือ "Load failed" — ส่วน CORS ก็ไม่ช่วยเพราะ request ไม่เคยถึง server · dev จึงเปิด endpoint `https://localhost:7232` ด้วย cert ชุดเดียวกับ Keycloak (`~/.office-addin-dev-certs`, ปิดได้ผ่าน `DevHttps:Enabled=false` ใน `appsettings.Development.json`) · add-in ชี้ API ที่ https ตัวนี้อัตโนมัติ
|
||||
|
||||
ค่าเชื่อมต่อ (DB/MinIO/Keycloak) อยู่ใน `appsettings.json` + `appsettings.Development.json` — override ผ่าน env ได้ทั้งหมด เช่น `ConnectionStrings__Default`, `MinIO__Endpoint`, `Authentication__Authority`
|
||||
|
||||
**API ที่มีตอนนี้** (ทุก endpoint ต้องแนบ `Authorization: Bearer <token>`):
|
||||
|
|
@ -96,7 +105,8 @@ dotnet run # Swagger ที่ http://localhost:5075/swagger
|
|||
# Documents / versions (FR-5–7, 11)
|
||||
POST /api/documents/{documentId}/versions # อัปโหลด version ใหม่ + field sync
|
||||
GET /api/documents/{documentId}/versions # version history ใหม่ → เก่า (พร้อม snapshot fields)
|
||||
GET /api/documents/{documentId}/versions/{versionId}/download # ดาวน์โหลด version (proxy stream จาก MinIO)
|
||||
GET /api/documents/{documentId}/versions/{versionId}/download # ดาวน์โหลด version (proxy stream จาก MinIO)
|
||||
GET /api/documents/{documentId}/versions/{versionId}/download-link # presigned URL อายุสั้น (ให้เบราว์เซอร์โหลดตรงจาก MinIO)
|
||||
|
||||
# Generate จาก template (FR-10)
|
||||
POST /api/documents/generate # { templateId, values } → เอกสาร v1 พร้อม metadata
|
||||
|
|
@ -117,12 +127,12 @@ DELETE /api/templates/{id} # deactivate (s
|
|||
|
||||
### ขอ token ทดสอบ (dev)
|
||||
|
||||
ทุก client บังคับ PKCE (ปิด direct grants ตาม spec) — วิธีเร็วสุดตอนทดสอบคือขอผ่าน client `admin-cli` ของ realm:
|
||||
ทุก client ใน realm บังคับ PKCE (ปิด direct grants ตาม spec) และ token ของ `admin-cli` **ไม่มี claim `aud`** จึงเรียก backend ไม่ผ่าน (401) — ใช้สคริปต์นี้เดิน PKCE flow จริงแทน (login ด้วย test user ให้อัตโนมัติ):
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8080/realms/word-versioning/protocol/openid-connect/token \
|
||||
-d "client_id=admin-cli&grant_type=password&username=testuser&password=Test1234!" \
|
||||
| jq -r .access_token
|
||||
python3 scripts/dev-token.py # ปริ้น access token
|
||||
python3 scripts/dev-token.py --clip # หรือคัดลอกเข้า clipboard
|
||||
TOKEN=$(python3 scripts/dev-token.py) # เก็บใส่ตัวแปร
|
||||
```
|
||||
|
||||
แล้วลอง upload:
|
||||
|
|
@ -186,13 +196,13 @@ curl -X POST http://localhost:5075/api/documents/$DOC_ID/versions \
|
|||
|
||||
แล้วดู `GET /api/documents/$DOC_ID/versions` — แต่ละ version มี snapshot fields ของตัวเอง หากลบ content control ออกจาก Word ก่อน upload field นั้นจะเห็น `"missing": true` โดยค่าเดิมยังคงอยู่ (ไม่ error)
|
||||
|
||||
### 3) Add-in (services layer พร้อม — UI ตามมาใน Phase 5)
|
||||
### 3) Add-in (ครบทั้ง services layer และ task pane UI)
|
||||
|
||||
```bash
|
||||
cd addin
|
||||
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว (https localhost)
|
||||
npm run build # webpack build + type-check ทั้ง program
|
||||
npm test # Jest — unit tests ของ services layer (40 ตัว)
|
||||
npm test # Jest — unit tests (45 ตัว)
|
||||
npm run lint # eslint (ใช้ eslint.config.mjs ของโปรเจกต์)
|
||||
npm run validate # ตรวจ manifest.xml
|
||||
npm start # dev server https://localhost:3000 — sideload ผ่าน manifest.xml
|
||||
|
|
@ -206,21 +216,35 @@ npm start # dev server https://localhost:3000 — sid
|
|||
| `metadataService.ts` | อ่าน/stamp custom properties (`SavedByAddIn` ฯลฯ), Custom XML Part แบบ delete-then-add (fail เงียบๆ ตาม known bug บน Mac), `readContentControlFields()` (FR-11) |
|
||||
| `fileService.ts` | ดึงไฟล์ปัจจุบันผ่าน File/Slice API ประกอบเป็น Blob — เรียก `closeAsync` เสมอ |
|
||||
| `apiClient.ts` | fetch wrapper แนบ Bearer, โดน 401 → refresh + retry ครั้งเดียว, ยัง fail → `AuthRequiredError` ให้ UI กลับหน้า sign-in |
|
||||
| `versioningService.ts` | uploadVersion (แนบ `fields`), getVersions, downloadVersion |
|
||||
| `versioningService.ts` | uploadVersion (แนบ `fields`), getVersions, downloadVersion + getDownloadLink (presigned) |
|
||||
| `versionFlow.ts` | orchestration ของ Start Versioning / Save & Version ตามลำดับ 7 ขั้นตอนของ spec |
|
||||
|
||||
หน้า login ของ add-in คือ [addin/src/taskpane/auth-callback.html](addin/src/taskpane/auth-callback.html) — redirect target ของ Keycloak (ตรง `https://localhost:3000/auth-callback.html` ใน realm-export.json)
|
||||
|
||||
**Config ฝั่ง add-in** ฉีดตอน build ผ่าน webpack DefinePlugin (อ่านจาก env — default ชี้ dev ในพื้นที่):
|
||||
|
||||
```bash
|
||||
KEYCLOAK_AUTHORITY=http://localhost:8080/realms/word-versioning \
|
||||
API_BASE_URL=http://localhost:5075 \
|
||||
KEYCLOAK_AUTHORITY=https://localhost:8443/realms/word-versioning \
|
||||
API_BASE_URL=https://localhost:7232 \
|
||||
npm run build
|
||||
```
|
||||
|
||||
(ตัวแปรที่เหลือ: `KEYCLOAK_CLIENT_ID` — default `word-addin-officejs`)
|
||||
|
||||
**ข้อจำกัดชั่วคราว:** task pane ยังเป็นหน้า demo ของ scaffold — Phase 5 จะเอา services ชุดนี้ไป wire เป็น UI จริง (sign-in → detect → Save & Version → history)
|
||||
**Task pane UI (Phase 5)** — state machine `checking → signed-out → untracked → tracking`:
|
||||
|
||||
- **Sign-in gate (FR-1)** — ยังไม่ login → ปุ่ม Sign in (เปิด Keycloak ผ่าน Office Dialog API) · login แล้วตรวจ property `SavedByAddIn` ในไฟล์: ตรง → โหลด version history ทันที (ไฟล์ที่สร้างจากเว็บเข้า state นี้เลย เห็น v1 โดยไม่ต้องกดอะไร) / ไม่ตรง → ปุ่ม **Start Versioning This Document**
|
||||
- **Save & Version (FR-2)** — confirm dialog → stamp properties → อัปเดต Custom XML Part → native save → ดึงไฟล์ + extract fields (FR-11) → upload → refresh history · ทุกขั้นมี loading + error ไม่มี unhandled rejection
|
||||
- **Soft reminder (FR-3)** — ระหว่าง tracking ถามทุก 30 วิ ว่ามีแก้ไขที่ยังไม่ได้ version → banner เตือน (ไม่ block)
|
||||
- **Version history (FR-4)** — รายการ v เวลา/ผู้สร้าง + ปุ่มดาวน์โหลด version นั้น (ขอ presigned URL อายุ 2 นาทีแล้วเปิดในเบราว์เซอร์ของเครื่อง — webview ของ Word ทิ้ง blob download แบบเงียบๆ ไฟล์จึงต้องมาจากเบราว์เซอร์จริง แล้วหาได้ในโฟลเดอร์ Downloads ของเบราว์เซอร์)
|
||||
- **Session หมดอายุ (AC-9)** — 401 → silent refresh → ยังไม่ได้ → กลับหน้า sign-in พร้อมข้อความ
|
||||
|
||||
**Sideload เพื่อทดสอบใน Word จริง** (ต้องรัน infra + backend + `npm start` ก่อน):
|
||||
|
||||
- **Word Desktop (Mac/Windows)**: `cd addin && npm start` (ใช้ office-addin-debugging sideload ให้อัตโนมัติ) หรือ insert manual: Word → Insert → My Add-ins → Upload My Add-in → เลือก `addin/manifest.xml`
|
||||
- **Word Online**: ไฟล์ใดก็ได้ใน OneDrive/SharePoint → Insert → Office Add-ins → Upload My Add-in → เลือก `addin/manifest.xml`
|
||||
- ไล่ทดสอบตาม acceptance criteria: login ผ่าน dialog → เปิดไฟล์ใหม่เห็นปุ่ม Start Versioning → กดแล้วได้ v1 → แก้ไขแล้ว Save & Version → history เพิ่ม + ดาวน์โหลด version เก่าเปิดได้
|
||||
- ⚠️ **ห้าม `cp` ทับ manifest ใน `~/Library/Containers/com.microsoft.Word/Data/Documents/wef/`** — `npm start` สร้าง symlink ไว้ การทับทำให้ครั้งถัดไปขึ้น `EEXIST` — ถาวรแก้ที่ `addin/manifest.xml` ต้นทางแล้ว restart Word (ถ้าเจอ EEXIST แล้ว: ลบไฟล์ใน wef ทิ้งแล้ว `npm start` ใหม่)
|
||||
|
||||
### 4) Web Portal (ยังเป็น scaffold)
|
||||
|
||||
|
|
@ -232,6 +256,22 @@ logic จริง (login, template UI, dynamic form) มาตาม Phase 6
|
|||
|
||||
> คำแนะนำฉบับเต็ม (sideload บน Word Desktop/Online, สร้าง template แรก, สร้างเอกสารจากเว็บ) จะเติมเมื่อ implement ครบตาม Phase 8 ของแผน
|
||||
|
||||
## Troubleshooting (จากการทดสอบจริงบน Word for Mac)
|
||||
|
||||
ปัญหาที่เจอตอน sideload test รอบแรกๆ พร้อมสาเหตุ/วิธีแก้ — แก้ไขในโค้ดแล้วทั้งหมด เก็บไว้เป็นบทเรียน:
|
||||
|
||||
| อาการ | สาเหตุ | วิธีแก้ |
|
||||
|---|---|---|
|
||||
| กด Sign in แล้ว "The HTTP protocol is not supported. Use HTTPS instead" | Office Dialog API **ปฏิเสธ http:// โดยสมบูรณ์** แต่ Keycloak dev เดิมรัน http://8080 | Keycloak เสิร์ฟ HTTPS ที่ `https://localhost:8443` ด้วย cert ของ office-addin-dev-certs (docker-compose mount `~/.office-addin-dev-certs` เข้า container) |
|
||||
| Login ผ่านแต่ทุก API call ขึ้น "Load failed" | **WKWebView บล็อก fetch จากหน้า https ไป http (mixed content)** — task pane เป็น `https://localhost:3000` แต่ API เป็น `http://localhost:5075` (CORS ไม่เกี่ยว — ตรวจแล้วเรียบร้อย) | backend เปิด endpoint `https://localhost:7232` ด้วย cert ชุดเดียวกัน (`DevHttps:*` ใน `appsettings.Development.json`) — add-in ชี้อันนี้อัตโนมัติ |
|
||||
| กด Start Versioning แล้ว "รูปแบบข้อมูลของ slice ไม่รองรับ" | host แต่ละตัวส่ง `slice.data` คนละรูป (Win = ArrayBuffer/view, **Mac = base64 string หรือ number[]**) | `fileService.sliceDataToBytes()` normalize ครบทั้ง 4 รูปแบบ |
|
||||
| กดดาวน์โหลด version แล้ว**ไม่มีไฟล์โผล่ที่ไหนเลย** | **webview ของ Word ทิ้ง blob download (`a[download]`) แบบเงียบๆ** | ขอ presigned URL อายุ 2 นาทีจาก `download-link` แล้วเปิดใน**เบราว์เซอร์ของเครื่อง** (`openBrowserWindow`) — ไฟล์ลงโฟลเดอร์ Downloads ของเบราว์เซอร์ |
|
||||
| แก้ webpack.config.js แล้วค่าเก่ายังติดอยู่ | webpack **ไม่ restart เองเมื่อ config เปลี่ยน** + dev-server ตัวเก่าถือ port 3000 อยู่ (ตัวใหม่ขึ้นไม่ได้) | kill dev-server เดิม (`lsof -ti :3000 | xargs kill`) แล้ว `npm start` ใหม่ทุกครั้งที่แก้ config |
|
||||
| `npm start` ขึ้น `EEXIST ... wef/...manifest.xml` | มีการ `cp` ทับ symlink ของ sideload จนกลายเป็นไฟล์ธรรมดา | ลบไฟล์นั้นใน wef folder ทิ้งแล้ว `npm start` ใหม่ (ดูคำเตือนในหัวข้อ Sideload) |
|
||||
| curl token จาก `admin-cli` แล้วเรียก backend ได้ 401 | token ของ admin-cli **ไม่มี claim `aud`** (ไม่ได้อยู่ใน realm-export จึงไม่มี audience mapper) | ใช้ `python3 scripts/dev-token.py` (เดิน PKCE จริงด้วย client `word-versioning-web`) |
|
||||
|
||||
**กติกาที่สรุปได้:** ใน dev ทุก service ที่ task pane (หน้า https) จะเรียก **ต้องเป็น HTTPS ด้วย cert จาก office-addin-dev-certs ทั้งหมด** — Keycloak (8443) และ backend (7232)
|
||||
|
||||
## เทคโนโลยี
|
||||
|
||||
- **Add-in**: Office.js (WordApi 1.5+), TypeScript, React, Fluent UI, webpack — scaffold ด้วย `yo office`
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/teams/v1.17/MicrosoftTeams.schema.json",
|
||||
"id": "f2b103f1-1ab1-4e1b-8f0b-072aa3d4e19d",
|
||||
"manifestVersion": "1.17",
|
||||
"version": "1.0.0",
|
||||
"name": {
|
||||
"short": "Contoso Task Pane Add-in",
|
||||
"full": "Contoso Task Pane Add-in"
|
||||
},
|
||||
"description": {
|
||||
"short": "A template to get started.",
|
||||
"full": "This is the template to get started."
|
||||
},
|
||||
"developer": {
|
||||
"name": "Contoso",
|
||||
"websiteUrl": "https://www.contoso.com",
|
||||
"privacyUrl": "https://www.contoso.com/privacy",
|
||||
"termsOfUseUrl": "https://www.contoso.com/servicesagreement"
|
||||
},
|
||||
"icons": {
|
||||
"outline": "assets/outline.png",
|
||||
"color": "assets/color.png"
|
||||
},
|
||||
"accentColor": "#230201",
|
||||
"localizationInfo": {
|
||||
"defaultLanguageTag": "en-us",
|
||||
"additionalLanguages": []
|
||||
},
|
||||
"authorization": {
|
||||
"permissions": {
|
||||
"resourceSpecific": [
|
||||
{
|
||||
"name": "Mailbox.ReadWrite.User",
|
||||
"type": "Delegated"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"validDomains": ["contoso.com"],
|
||||
"extensions": [
|
||||
{
|
||||
"requirements": {
|
||||
"scopes": ["mail"],
|
||||
"capabilities": [
|
||||
{ "name": "Mailbox", "minVersion": "1.3" }
|
||||
]
|
||||
},
|
||||
"runtimes": [
|
||||
{
|
||||
"requirements": {
|
||||
"capabilities": [
|
||||
{ "name": "Mailbox", "minVersion": "1.3" }
|
||||
]
|
||||
},
|
||||
"id": "TaskPaneRuntime",
|
||||
"type": "general",
|
||||
"code": {
|
||||
"page": "https://localhost:3000/taskpane.html"
|
||||
},
|
||||
"lifetime": "short",
|
||||
"actions": [
|
||||
{
|
||||
"id": "TaskPaneRuntimeShow",
|
||||
"type":"openPage",
|
||||
"pinnable": false,
|
||||
"view": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "CommandsRuntime",
|
||||
"type": "general",
|
||||
"code": {
|
||||
"page": "https://localhost:3000/commands.html",
|
||||
"script": "https://localhost:3000/commands.js"
|
||||
},
|
||||
"lifetime": "short",
|
||||
"actions": [
|
||||
{
|
||||
"id": "action",
|
||||
"type": "executeFunction"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ribbons": [
|
||||
{
|
||||
"contexts": [
|
||||
"mailCompose"
|
||||
],
|
||||
"tabs": [
|
||||
{
|
||||
"builtInTabId": "TabDefault",
|
||||
"groups": [
|
||||
{
|
||||
"id": "msgComposeGroup",
|
||||
"label": "Contoso Add-in",
|
||||
"icons": [
|
||||
{ "size": 16, "url": "https://localhost:3000/assets/icon-16.png" },
|
||||
{ "size": 32, "url": "https://localhost:3000/assets/icon-32.png" },
|
||||
{ "size": 80, "url": "https://localhost:3000/assets/icon-80.png" }
|
||||
],
|
||||
"controls": [
|
||||
{
|
||||
"id": "msgComposeOpenPaneButton",
|
||||
"type": "button",
|
||||
"label": "Show Task Pane",
|
||||
"icons": [
|
||||
{ "size": 16, "url": "https://localhost:3000/assets/icon-16.png" },
|
||||
{ "size": 32, "url": "https://localhost:3000/assets/icon-32.png" },
|
||||
{ "size": 80, "url": "https://localhost:3000/assets/icon-80.png" }
|
||||
],
|
||||
"supertip": {
|
||||
"title": "Show Task Pane",
|
||||
"description": "Opens a task pane."
|
||||
},
|
||||
"actionId": "TaskPaneRuntimeShow"
|
||||
},
|
||||
{
|
||||
"id": "ActionButton",
|
||||
"type": "button",
|
||||
"label": "Perform an action",
|
||||
"icons": [
|
||||
{ "size": 16, "url": "https://localhost:3000/assets/icon-16.png" },
|
||||
{ "size": 32, "url": "https://localhost:3000/assets/icon-32.png" },
|
||||
{ "size": 80, "url": "https://localhost:3000/assets/icon-80.png" }
|
||||
],
|
||||
"supertip": {
|
||||
"title": "Perform an action",
|
||||
"description": "Perform an action when clicked."
|
||||
},
|
||||
"actionId": "action"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -4,8 +4,8 @@
|
|||
<Version>1.0.0.0</Version>
|
||||
<ProviderName>Contoso</ProviderName>
|
||||
<DefaultLocale>en-US</DefaultLocale>
|
||||
<DisplayName DefaultValue="addin"/>
|
||||
<Description DefaultValue="A template to get started."/>
|
||||
<DisplayName DefaultValue="e-Court Documents Versioning"/>
|
||||
<Description DefaultValue="Opens the e-Court Documents Versioning task pane."/>
|
||||
<IconUrl DefaultValue="https://localhost:3000/assets/icon-32.png"/>
|
||||
<HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-64.png"/>
|
||||
<SupportUrl DefaultValue="https://www.contoso.com/help"/>
|
||||
|
|
@ -22,6 +22,8 @@
|
|||
<Sets DefaultMinVersion="1.1">
|
||||
<Set Name="WordApi" MinVersion="1.5"/>
|
||||
<Set Name="DialogAPI" MinVersion="1.1"/>
|
||||
<!-- เปิด presigned download URL ในเบราว์เซอร์ของเครื่อง (webview ของ Word ทิ้ง blob download) -->
|
||||
<Set Name="OpenBrowserWindowApi" MinVersion="1.1"/>
|
||||
</Sets>
|
||||
</Requirements>
|
||||
<DefaultSettings>
|
||||
|
|
@ -82,12 +84,12 @@
|
|||
</bt:Urls>
|
||||
<bt:ShortStrings>
|
||||
<bt:String id="GetStarted.Title" DefaultValue="Get started with your sample add-in!"/>
|
||||
<bt:String id="CommandsGroup.Label" DefaultValue="Commands Group"/>
|
||||
<bt:String id="TaskpaneButton.Label" DefaultValue="Show Task Pane"/>
|
||||
<bt:String id="CommandsGroup.Label" DefaultValue="e-Court Add-in"/>
|
||||
<bt:String id="TaskpaneButton.Label" DefaultValue="e-Court Documents Versioning"/>
|
||||
</bt:ShortStrings>
|
||||
<bt:LongStrings>
|
||||
<bt:String id="GetStarted.Description" DefaultValue="Your sample add-in loaded successfully. Go to the HOME tab and click the 'Show Task Pane' button to get started."/>
|
||||
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Click to Show a Task Pane"/>
|
||||
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Opens the e-Court Documents Versioning task pane."/>
|
||||
</bt:LongStrings>
|
||||
</Resources>
|
||||
</VersionOverrides>
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@
|
|||
*/
|
||||
export const config = {
|
||||
keycloak: {
|
||||
/** เช่น http://localhost:8080/realms/word-versioning */
|
||||
/** เช่น https://localhost:8443/realms/word-versioning (ต้องเป็น HTTPS เสมอ — Office Dialog API ปฏิเสธ http) */
|
||||
authority: __KEYCLOAK_AUTHORITY__,
|
||||
/** public client ของ add-in (PKCE S256 บังคับ) */
|
||||
clientId: __KEYCLOAK_CLIENT_ID__,
|
||||
},
|
||||
/** base URL ของ backend API (ไม่มี slash ท้าย) — เช่น http://localhost:5075 */
|
||||
/** base URL ของ backend API (ไม่มี slash ท้าย) — เช่น https://localhost:7232 (ต้องเป็น HTTPS — webview บล็อก http) */
|
||||
apiBaseUrl: __API_BASE_URL__,
|
||||
/** redirect URI ที่ลงทะเบียนไว้กับ Keycloak — หน้าเล็กใน add-in เอง (รันใน Office Dialog) */
|
||||
get redirectUri(): string {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import * as authService from "./authService";
|
|||
import { apiFetch, apiGetJson, AuthRequiredError, readErrorMessage } from "./apiClient";
|
||||
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
|
||||
|
||||
const API_URL = "http://localhost:5075";
|
||||
const API_URL = "https://localhost:7232";
|
||||
|
||||
beforeEach(() => {
|
||||
authService.signOut();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { createHash } from "crypto";
|
|||
import * as authService from "./authService";
|
||||
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
|
||||
|
||||
const TOKEN_ENDPOINT = "http://localhost:8080/realms/word-versioning/protocol/openid-connect/token";
|
||||
const TOKEN_ENDPOINT =
|
||||
"https://localhost:8443/realms/word-versioning/protocol/openid-connect/token";
|
||||
|
||||
function base64Url(buffer: Buffer): string {
|
||||
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
|
|
@ -110,7 +111,7 @@ describe("buildAuthorizationUrl", () => {
|
|||
authService.buildAuthorizationUrl({ codeChallenge: "the-challenge", state: "the-state" })
|
||||
);
|
||||
|
||||
expect(url.origin).toBe("http://localhost:8080");
|
||||
expect(url.origin).toBe("https://localhost:8443");
|
||||
expect(url.pathname).toBe("/realms/word-versioning/protocol/openid-connect/auth");
|
||||
expect(url.searchParams.get("client_id")).toBe("word-addin-officejs");
|
||||
expect(url.searchParams.get("redirect_uri")).toBe("http://localhost/auth-callback.html");
|
||||
|
|
@ -138,11 +139,11 @@ describe("decodeJwtExpiry", () => {
|
|||
describe("login (Office Dialog + token exchange)", () => {
|
||||
it("เต็ม flow: URL มี PKCE → dialog ส่ง code+state → แลก token ด้วย verifier ที่ pair กัน → isTokenValid", async () => {
|
||||
const office = installOfficeDialogMock();
|
||||
let exchangedBody: URLSearchParams | null = null;
|
||||
const exchanged: { body?: URLSearchParams } = {};
|
||||
const fetchMock = installFetchMock();
|
||||
fetchMock.mockImplementation(async (input: unknown, init?: { body?: string }) => {
|
||||
expect(String(input)).toBe(TOKEN_ENDPOINT);
|
||||
exchangedBody = new URLSearchParams(String(init?.body));
|
||||
exchanged.body = new URLSearchParams(String(init?.body));
|
||||
return mockResponse({
|
||||
status: 200,
|
||||
body: {
|
||||
|
|
@ -169,10 +170,10 @@ describe("login (Office Dialog + token exchange)", () => {
|
|||
expect(office.dialog.close).toHaveBeenCalled();
|
||||
|
||||
// verifier ที่ใช้แลก token ต้อง hash ได้ = challenge ใน URL (pair กันจริง)
|
||||
const verifier = exchangedBody?.get("code_verifier") ?? "";
|
||||
expect(exchangedBody?.get("grant_type")).toBe("authorization_code");
|
||||
expect(exchangedBody?.get("code")).toBe("the-auth-code");
|
||||
expect(exchangedBody?.get("client_id")).toBe("word-addin-officejs");
|
||||
const verifier = exchanged.body?.get("code_verifier") ?? "";
|
||||
expect(exchanged.body?.get("grant_type")).toBe("authorization_code");
|
||||
expect(exchanged.body?.get("code")).toBe("the-auth-code");
|
||||
expect(exchanged.body?.get("client_id")).toBe("word-addin-officejs");
|
||||
expect(base64Url(createHash("sha256").update(verifier).digest())).toBe(challenge);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -216,6 +216,32 @@ export function decodeJwtExpiry(token: string): number | null {
|
|||
}
|
||||
}
|
||||
|
||||
/** อ่านชื่อผู้ใช้จาก access token ปัจจุบัน (`preferred_username` แล้ว fallback `sub`) — ใช้ใส่ใน XML history */
|
||||
export function getUsername(): string | undefined {
|
||||
if (!tokenState.accessToken) {
|
||||
return undefined;
|
||||
}
|
||||
const parts = tokenState.accessToken.split(".");
|
||||
if (parts.length !== 3) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(base64UrlDecode(parts[1])) as {
|
||||
preferred_username?: unknown;
|
||||
sub?: unknown;
|
||||
};
|
||||
if (typeof payload.preferred_username === "string" && payload.preferred_username) {
|
||||
return payload.preferred_username;
|
||||
}
|
||||
if (typeof payload.sub === "string" && payload.sub) {
|
||||
return payload.sub;
|
||||
}
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State transitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
10
addin/src/services/errors.ts
Normal file
10
addin/src/services/errors.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/** ข้อความ error ที่อ่านได้สำหรับแสดงใน task pane — ไม่เคย throw (กติกา: ทุก Office.js/API call ต้อง catch) */
|
||||
export function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string" && error) {
|
||||
return error;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
|
@ -9,10 +9,28 @@ interface FileMockState {
|
|||
/** mock Office.context.document.getFileAsync + File.getSliceAsync/closeAsync */
|
||||
function installOfficeFileMock(
|
||||
slices: Uint8Array[],
|
||||
options: { failSliceIndex?: number; failClose?: boolean; sliceDataForm?: "buffer" | "view" } = {}
|
||||
options: {
|
||||
failSliceIndex?: number;
|
||||
failClose?: boolean;
|
||||
/** รูปร่างของ slice.data ที่ host จริงส่งกลับ — ต่างกันตามแพลตฟอร์ม (Win=ArrayBuffer, Mac=string/array) */
|
||||
sliceDataForm?: "buffer" | "view" | "array" | "base64";
|
||||
} = {}
|
||||
): FileMockState {
|
||||
const state: FileMockState = { getFileCalls: 0, requestedSlices: [], closeCalls: 0 };
|
||||
|
||||
const toSliceData = (bytes: Uint8Array): unknown => {
|
||||
switch (options.sliceDataForm) {
|
||||
case "view":
|
||||
return bytes;
|
||||
case "array":
|
||||
return Array.from(bytes);
|
||||
case "base64":
|
||||
return Buffer.from(bytes).toString("base64");
|
||||
default:
|
||||
return bytes.buffer;
|
||||
}
|
||||
};
|
||||
|
||||
const file = {
|
||||
sliceCount: slices.length,
|
||||
getSliceAsync: (index: number, callback: (result: unknown) => void) => {
|
||||
|
|
@ -20,9 +38,10 @@ function installOfficeFileMock(
|
|||
if (options.failSliceIndex === index) {
|
||||
callback({ status: "failed", error: { message: `อ่าน slice ${index} พัง (mock)` } });
|
||||
} else {
|
||||
// host จริงอาจส่งมาเป็น ArrayBuffer หรือ view — ทดสอบทั้งคู่
|
||||
const data = options.sliceDataForm === "view" ? slices[index] : slices[index].buffer;
|
||||
callback({ status: "succeeded", value: { index, data, size: slices[index].byteLength } });
|
||||
callback({
|
||||
status: "succeeded",
|
||||
value: { index, data: toSliceData(slices[index]), size: slices[index].byteLength },
|
||||
});
|
||||
}
|
||||
},
|
||||
closeAsync: (callback?: (result: unknown) => void) => {
|
||||
|
|
@ -81,6 +100,26 @@ describe("getCurrentDocumentFile", () => {
|
|||
expect(state.closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("slice มาเป็น byte array ธรรมดา number[] (Word บน Mac) ก็ประกอบได้", async () => {
|
||||
const state = installOfficeFileMock([bytes(10, 20), bytes(30, 40)], { sliceDataForm: "array" });
|
||||
|
||||
const blob = await getCurrentDocumentFile();
|
||||
|
||||
expect(blob.size).toBe(4);
|
||||
expect(state.closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("slice มาเป็น base64 string ก็ decode แล้วประกอบได้", async () => {
|
||||
const state = installOfficeFileMock([bytes(200, 100, 50), bytes(255)], {
|
||||
sliceDataForm: "base64",
|
||||
});
|
||||
|
||||
const blob = await getCurrentDocumentFile();
|
||||
|
||||
expect(blob.size).toBe(4);
|
||||
expect(state.closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("อ่าน slice กลางที่พัง → reject พร้อม error แต่ยังเรียก closeAsync เสมอ", async () => {
|
||||
const state = installOfficeFileMock([bytes(1), bytes(2), bytes(3)], { failSliceIndex: 1 });
|
||||
|
||||
|
|
|
|||
|
|
@ -35,20 +35,48 @@ function getFileAsync(): Promise<Office.File> {
|
|||
});
|
||||
}
|
||||
|
||||
/** host ส่ง slice มาเป็น ArrayBuffer หรือ view — รับทั้งคู่แล้วคืนเป็น BlobPart ตรงๆ */
|
||||
/**
|
||||
* host แต่ละตัวส่งข้อมูล slice มาคนละรูปแบบ (เอกสาร Office.js ระบบแค่ "byte array"):
|
||||
* - Word บน Windows ส่วนใหญ่ = ArrayBuffer / typed-array view
|
||||
* - Word บน Mac = base64 string หรือ byte array ธรรมดา (number[])
|
||||
* ปรับเป็น Uint8Array ให้หมดก่อนประกอบเป็น Blob
|
||||
*/
|
||||
export function sliceDataToBytes(data: unknown): Uint8Array<ArrayBuffer> {
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return new Uint8Array(data);
|
||||
}
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
const view = data as ArrayBufferView;
|
||||
return new Uint8Array(view.buffer as ArrayBuffer, view.byteOffset, view.byteLength);
|
||||
}
|
||||
if (typeof data === "string") {
|
||||
// base64 (บาง host ใส่ data-url prefix หรือขึ้นบรรทัดมาด้วย — ตัดทิ้งก่อน decode)
|
||||
const base64 = data.replace(/^data:[^,]*?;base64,/, "").replace(/\s/g, "");
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index++) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
if (Array.isArray(data) && (data.length === 0 || typeof data[0] === "number")) {
|
||||
return Uint8Array.from(data);
|
||||
}
|
||||
throw new Error(`รูปแบบข้อมูลของ slice ไม่รองรับ (ได้รับ ${typeof data})`);
|
||||
}
|
||||
|
||||
/** อ่าน slice หนึ่งชิ้นแล้ว normalize เป็น Uint8Array */
|
||||
function getSliceAsync(file: Office.File, sliceIndex: number): Promise<BlobPart> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
file.getSliceAsync(sliceIndex, (result) => {
|
||||
if (result.status === Office.AsyncResultStatus.Succeeded) {
|
||||
const data: unknown = result.value.data;
|
||||
if (data instanceof ArrayBuffer) {
|
||||
resolve(data);
|
||||
} else if (ArrayBuffer.isView(data)) {
|
||||
const view = data as ArrayBufferView;
|
||||
resolve(new Uint8Array(view.buffer as ArrayBuffer, view.byteOffset, view.byteLength));
|
||||
} else {
|
||||
reject(new Error("รูปแบบข้อมูลของ slice ไม่รองรับ (ต้องเป็น byte array)"));
|
||||
try {
|
||||
resolve(sliceDataToBytes(result.value.data));
|
||||
} catch (error) {
|
||||
reject(
|
||||
error instanceof Error ? error : new Error(`อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
reject(officeError(result, `อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`));
|
||||
|
|
|
|||
216
addin/src/services/versionFlow.test.ts
Normal file
216
addin/src/services/versionFlow.test.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import * as authService from "./authService";
|
||||
import { ADDIN_SIGNATURE_GUID, buildVersionHistoryXml } from "./metadataService";
|
||||
import { saveAndVersion, startVersioning } from "./versionFlow";
|
||||
import { installWordMock } from "../test/wordMock";
|
||||
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
|
||||
|
||||
/** mock Office.context.document (url สำหรับชื่อไฟล์ + getFileAsync สำหรับ fileService) */
|
||||
function installOfficeDocumentMock(url: string | undefined): void {
|
||||
const slice = new Uint8Array([1, 2, 3, 4]);
|
||||
(globalThis as unknown as Record<string, unknown>).Office = {
|
||||
FileType: { Compressed: "compressed", Text: "text" },
|
||||
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
|
||||
context: {
|
||||
document: {
|
||||
url,
|
||||
getFileAsync: (
|
||||
_fileType: unknown,
|
||||
_options: unknown,
|
||||
callback: (result: unknown) => void
|
||||
) => {
|
||||
callback({
|
||||
status: "succeeded",
|
||||
value: {
|
||||
sliceCount: 1,
|
||||
getSliceAsync: (index: number, cb: (result: unknown) => void) =>
|
||||
cb({ status: "succeeded", value: { index, data: slice.buffer } }),
|
||||
closeAsync: (cb?: (result: unknown) => void) => cb?.({ status: "succeeded" }),
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function seedSession(): void {
|
||||
authService.setSessionForTest(
|
||||
makeFakeJwt(Date.now() + 600_000, "user-sub", { preferred_username: "tester" }),
|
||||
"refresh-1",
|
||||
Date.now() + 600_000
|
||||
);
|
||||
}
|
||||
|
||||
function parseUploadCall(call: [string, { body?: FormData }]): { url: URL; form: FormData } {
|
||||
return { url: new URL(call[0]), form: call[1].body as FormData };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
seedSession();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
authService.signOut();
|
||||
});
|
||||
|
||||
describe("startVersioning (FR-1 / AC-5)", () => {
|
||||
it("stamp รอบแรกครบ → save → upload v1 พร้อม fields + ชื่อไฟล์จาก URL → เขียน XML part v1", async () => {
|
||||
const state = installWordMock({
|
||||
contentControls: [
|
||||
{ tag: "employee_name", text: "สมชาย ใจดี" },
|
||||
{ tag: "salary", text: "50000" },
|
||||
],
|
||||
});
|
||||
installOfficeDocumentMock("C:\\Users\\tester\\Documents\\สัญญาจ้าง.docx");
|
||||
const fetchMock = installFetchMock();
|
||||
fetchMock.mockImplementationOnce(async () =>
|
||||
mockResponse({ status: 201, body: { versionId: "backend-version-1", versionNumber: 1 } })
|
||||
);
|
||||
|
||||
const result = await startVersioning();
|
||||
|
||||
// ผลลัพธ์ผูกกันทั้งชุด
|
||||
expect(result).toEqual({
|
||||
documentId: result.documentId,
|
||||
versionId: "backend-version-1",
|
||||
versionNumber: 1,
|
||||
});
|
||||
expect(result.documentId).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
|
||||
// ครบทั้ง 4 properties + SavedByAddIn = GUID ของ add-in
|
||||
expect(state.properties.SavedByAddIn).toBe(ADDIN_SIGNATURE_GUID);
|
||||
expect(state.properties.DocumentTrackingId).toBe(result.documentId);
|
||||
expect(state.properties.LastVersionId).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
expect(state.properties.LastVersionTimestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
// เรียก native save หนึ่งครั้ง (FR-2 ขั้นตอนที่ 4)
|
||||
expect(state.saveCalls).toBe(1);
|
||||
|
||||
// upload ไปที่ documentId ที่ generate + แนบ fields + clientVersionId + ชื่อไฟล์จาก URL
|
||||
const { url, form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
|
||||
expect(`${url.origin}${url.pathname}`).toBe(
|
||||
`https://localhost:7232/api/documents/${result.documentId}/versions`
|
||||
);
|
||||
expect(String(form.get("clientVersionId"))).toBe(state.properties.LastVersionId);
|
||||
expect(JSON.parse(String(form.get("fields")))).toEqual({
|
||||
employee_name: "สมชาย ใจดี",
|
||||
salary: "50000",
|
||||
});
|
||||
expect(String((form.get("file") as File).name)).toBe("สัญญาจ้าง.docx");
|
||||
|
||||
// XML part มี entry v1 ผูกกับ clientVersionId เดียวกับ LastVersionId
|
||||
expect(state.xmlParts).toHaveLength(1);
|
||||
expect(state.xmlParts[0].xml).toContain('id="');
|
||||
expect(state.xmlParts[0].xml).toContain('number="1"');
|
||||
expect(state.xmlParts[0].xml).toContain('user="tester"');
|
||||
});
|
||||
|
||||
it("URL ไม่มี (Word Online) → ชื่อไฟล์ fallback", async () => {
|
||||
installWordMock({});
|
||||
installOfficeDocumentMock(undefined);
|
||||
const fetchMock = installFetchMock();
|
||||
fetchMock.mockImplementationOnce(async () =>
|
||||
mockResponse({ status: 201, body: { versionId: "v", versionNumber: 1 } })
|
||||
);
|
||||
|
||||
await startVersioning();
|
||||
|
||||
const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
|
||||
expect(String((form.get("file") as File).name)).toMatch(/\.docx$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveAndVersion (FR-2 / AC-7, 13)", () => {
|
||||
it("stamp → append XML entry (เดาเลขถัดไป) → save → upload พร้อม fields ล่าสุด", async () => {
|
||||
const state = installWordMock({
|
||||
properties: {
|
||||
SavedByAddIn: ADDIN_SIGNATURE_GUID,
|
||||
DocumentTrackingId: "11111111-1111-1111-1111-111111111111",
|
||||
LastVersionId: "old-client-version-id",
|
||||
},
|
||||
xmlParts: [
|
||||
buildVersionHistoryXml([
|
||||
{
|
||||
id: "old-client-version-id",
|
||||
number: 1,
|
||||
timestamp: "2026-08-19T09:00:00Z",
|
||||
user: "tester",
|
||||
},
|
||||
]),
|
||||
],
|
||||
contentControls: [{ tag: "salary", text: "60000" }],
|
||||
});
|
||||
installOfficeDocumentMock(undefined);
|
||||
const fetchMock = installFetchMock();
|
||||
fetchMock.mockImplementationOnce(async () =>
|
||||
mockResponse({ status: 201, body: { versionId: "backend-version-2", versionNumber: 2 } })
|
||||
);
|
||||
|
||||
const result = await saveAndVersion("11111111-1111-1111-1111-111111111111", 1);
|
||||
|
||||
expect(result).toEqual({ versionId: "backend-version-2", versionNumber: 2 });
|
||||
|
||||
// properties: tracking id คงเดิม, LastVersionId เปลี่ยนเป็น clientVersionId ใหม่
|
||||
expect(state.properties.DocumentTrackingId).toBe("11111111-1111-1111-1111-111111111111");
|
||||
const newLastVersionId = state.properties.LastVersionId;
|
||||
expect(newLastVersionId).not.toBe("old-client-version-id");
|
||||
expect(state.saveCalls).toBe(1);
|
||||
|
||||
// XML part: 2 entries เรียง v2 ก่อน v1
|
||||
expect(state.xmlParts).toHaveLength(1);
|
||||
const xml = state.xmlParts[0].xml;
|
||||
expect(xml).toContain('number="2"');
|
||||
expect(xml).toContain('number="1"');
|
||||
expect(xml.indexOf('number="2"')).toBeLessThan(xml.indexOf('number="1"'));
|
||||
|
||||
// upload ไปยัง document เดิม + fields ล่าสุด
|
||||
const { url, form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
|
||||
expect(url.pathname).toBe("/api/documents/11111111-1111-1111-1111-111111111111/versions");
|
||||
expect(String(form.get("clientVersionId"))).toBe(newLastVersionId);
|
||||
expect(JSON.parse(String(form.get("fields")))).toEqual({ salary: "60000" });
|
||||
});
|
||||
|
||||
it("backend ตอบเลข version ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรงเลขจริง", async () => {
|
||||
const state = installWordMock({
|
||||
properties: {
|
||||
SavedByAddIn: ADDIN_SIGNATURE_GUID,
|
||||
DocumentTrackingId: "11111111-1111-1111-1111-111111111111",
|
||||
LastVersionId: "old",
|
||||
},
|
||||
xmlParts: [buildVersionHistoryXml([{ id: "old", number: 1, timestamp: "t", user: "u" }])],
|
||||
});
|
||||
installOfficeDocumentMock(undefined);
|
||||
const fetchMock = installFetchMock();
|
||||
fetchMock.mockImplementationOnce(async () =>
|
||||
mockResponse({ status: 201, body: { versionId: "backend-version-5", versionNumber: 5 } })
|
||||
);
|
||||
|
||||
const result = await saveAndVersion("11111111-1111-1111-1111-111111111111", 1);
|
||||
|
||||
expect(result.versionNumber).toBe(5);
|
||||
// entry ใหม่ถูกแก้จาก 2 (ที่เดา) เป็น 5 (ของจริง)
|
||||
const xml = state.xmlParts[0].xml;
|
||||
expect(xml).toContain('number="5"');
|
||||
expect(xml).not.toContain('number="2"');
|
||||
expect(xml).toContain('number="1"');
|
||||
});
|
||||
|
||||
it("XML part เขียนไม่ได้ (known bug บน Mac) → flow ยังสำเร็จต่อ (AC-10)", async () => {
|
||||
installWordMock({
|
||||
properties: {
|
||||
SavedByAddIn: ADDIN_SIGNATURE_GUID,
|
||||
DocumentTrackingId: "11111111-1111-1111-1111-111111111111",
|
||||
},
|
||||
failXmlAdd: true,
|
||||
});
|
||||
installOfficeDocumentMock(undefined);
|
||||
const fetchMock = installFetchMock();
|
||||
fetchMock.mockImplementationOnce(async () =>
|
||||
mockResponse({ status: 201, body: { versionId: "backend-version-2", versionNumber: 2 } })
|
||||
);
|
||||
|
||||
await expect(saveAndVersion("11111111-1111-1111-1111-111111111111", 1)).resolves.toEqual({
|
||||
versionId: "backend-version-2",
|
||||
versionNumber: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
159
addin/src/services/versionFlow.ts
Normal file
159
addin/src/services/versionFlow.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/* global Word, Office */
|
||||
|
||||
import * as authService from "./authService";
|
||||
import { getCurrentDocumentFile } from "./fileService";
|
||||
import {
|
||||
generateGuid,
|
||||
readContentControlFields,
|
||||
readVersionHistoryFromXml,
|
||||
stampTrackedProperties,
|
||||
writeVersionHistoryToXml,
|
||||
XmlVersionEntry,
|
||||
} from "./metadataService";
|
||||
import { uploadVersion } from "./versioningService";
|
||||
|
||||
/**
|
||||
* Orchestration ของ flow หลักสองอันของ add-in (แยกจาก UI เพื่อ unit test ได้):
|
||||
*
|
||||
* - `startVersioning` (FR-1 "Start Versioning This Document") — เอกสารใหม่ที่ยังไม่ถูก track
|
||||
* - `saveAndVersion` (FR-2) — ไล่ตามลำดับใน spec แบบเคร่งครัด:
|
||||
* stamp properties → XML part (delete-then-add, fail ได้เงียบๆ) → document.save()
|
||||
* → ดึงไฟล์ + extract fields (FR-11) → POST version → คืนผลให้ UI แสดง/refresh history
|
||||
*
|
||||
* หมายเหตุค่า id ใน metadata: `LastVersionId` และ `<Version id>` ใน XML part เก็บ
|
||||
* **clientVersionId** (GUID ที่ client generate) เพราะต้อง stamp ลงไฟล์ *ก่อน* upload —
|
||||
* versionId ของ backend จะได้มาหลัง upload ซึ่งไฟล์ถูก capture ไปแล้ว
|
||||
* (backend คือ source of truth ของ id จริงอยู่ดี)
|
||||
*/
|
||||
|
||||
export interface StartVersioningResult {
|
||||
documentId: string;
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
}
|
||||
|
||||
export interface SaveAndVersionResult {
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
}
|
||||
|
||||
/** เลข version ถัดไปตามที่ client เดา (history ล่าสุด + 1) — ใช้เขียน XML part ก่อนรู้ค่าจริงจาก backend */
|
||||
function predictNextVersionNumber(latestVersionNumber: number): number {
|
||||
return latestVersionNumber + 1;
|
||||
}
|
||||
|
||||
/** ชื่อไฟล์จาก URL ของ document ถ้าดึงได้ (desktop มักเป็น path จริง) — ใช้เป็นชื่อตอน upload */
|
||||
function guessDocumentFileName(): string | undefined {
|
||||
try {
|
||||
const url = Office.context.document.url;
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
const name = url.split(/[\\/]/).pop();
|
||||
if (!name) {
|
||||
return undefined;
|
||||
}
|
||||
return name.toLowerCase().endsWith(".docx") ? name : `${name}.docx`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** `context.document.save(Word.SaveBehavior.save)` — trigger native save ตาม FR-2 ขั้นตอนที่ 4 */
|
||||
async function saveDocument(): Promise<void> {
|
||||
await Word.run(async (context) => {
|
||||
context.document.save(Word.SaveBehavior.save);
|
||||
await context.sync();
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadCurrentFile(
|
||||
documentId: string,
|
||||
clientVersionId: string,
|
||||
fields: Record<string, string>
|
||||
): Promise<{ versionId: string; versionNumber: number }> {
|
||||
const file = await getCurrentDocumentFile();
|
||||
return uploadVersion(documentId, file, clientVersionId, fields, guessDocumentFileName());
|
||||
}
|
||||
|
||||
/** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1 */
|
||||
export async function startVersioning(
|
||||
onProgress?: (message: string) => void
|
||||
): Promise<StartVersioningResult> {
|
||||
const documentId = generateGuid();
|
||||
const clientVersionId = generateGuid();
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// 1) stamp รอบแรก — ได้ทั้ง SavedByAddIn และ DocumentTrackingId ใหม่
|
||||
onProgress?.("กำลังบันทึก metadata ของเอกสาร…");
|
||||
await stampTrackedProperties({
|
||||
documentTrackingId: documentId,
|
||||
lastVersionId: clientVersionId,
|
||||
lastVersionTimestamp: timestamp,
|
||||
});
|
||||
|
||||
// 2) XML part (secondary — fail ได้เงียบๆ)
|
||||
onProgress?.("กำลังอัปเดต version history ในไฟล์…");
|
||||
await writeVersionHistoryToXml([
|
||||
{ id: clientVersionId, number: 1, timestamp, user: authService.getUsername() ?? "unknown" },
|
||||
]);
|
||||
|
||||
// 3) native save แล้ว capture ไฟล์ + fields
|
||||
onProgress?.("กำลังบันทึกไฟล์…");
|
||||
await saveDocument();
|
||||
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
|
||||
const fields = await readContentControlFields();
|
||||
|
||||
// 4) upload version แรก
|
||||
onProgress?.("กำลังอัปโหลด version 1…");
|
||||
const result = await uploadCurrentFile(documentId, clientVersionId, fields);
|
||||
return { documentId, ...result };
|
||||
}
|
||||
|
||||
/** FR-2 — Save & Version ของเอกสารที่ track อยู่แล้ว (เรียกหลังผู้ใช้กดยืนยันใน dialog แล้ว) */
|
||||
export async function saveAndVersion(
|
||||
documentId: string,
|
||||
latestVersionNumber: number,
|
||||
onProgress?: (message: string) => void
|
||||
): Promise<SaveAndVersionResult> {
|
||||
const clientVersionId = generateGuid();
|
||||
const timestamp = new Date().toISOString();
|
||||
const predictedNumber = predictNextVersionNumber(latestVersionNumber);
|
||||
const user = authService.getUsername() ?? "unknown";
|
||||
|
||||
// 1) stamp properties (SavedByAddIn + DocumentTrackingId ถูก stamp ไว้แล้วจากรอบแรก — เขียนทับไม่เสียหาย)
|
||||
onProgress?.("กำลังบันทึก metadata ของ version ใหม่…");
|
||||
await stampTrackedProperties({
|
||||
lastVersionId: clientVersionId,
|
||||
lastVersionTimestamp: timestamp,
|
||||
});
|
||||
|
||||
// 2) XML part: เพิ่ม entry ของ version ใหม่ไว้ต้นสุด=ใหม่→เก่า ตามรูปใน spec (เดาเลขก่อน upload) — fail ได้เงียบๆ
|
||||
onProgress?.("กำลังอัปเดต version history ในไฟล์…");
|
||||
const entries = await readVersionHistoryFromXml();
|
||||
entries.unshift({ id: clientVersionId, number: predictedNumber, timestamp, user });
|
||||
await writeVersionHistoryToXml(entries);
|
||||
|
||||
// 3) native save → capture ไฟล์ + fields ปัจจุบัน (FR-11)
|
||||
onProgress?.("กำลังบันทึกไฟล์…");
|
||||
await saveDocument();
|
||||
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
|
||||
const fields = await readContentControlFields();
|
||||
|
||||
// 4) upload
|
||||
onProgress?.("กำลังอัปโหลด version ใหม่…");
|
||||
const result = await uploadCurrentFile(documentId, clientVersionId, fields);
|
||||
|
||||
// 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort)
|
||||
if (result.versionNumber !== predictedNumber) {
|
||||
const corrected = await readVersionHistoryFromXml();
|
||||
const entry = corrected.find((item: XmlVersionEntry) => item.id === clientVersionId);
|
||||
if (entry) {
|
||||
entry.number = result.versionNumber;
|
||||
corrected.sort((a, b) => b.number - a.number); // คงลำดับใหม่→เก่าหลังแก้เลข
|
||||
await writeVersionHistoryToXml(corrected);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -42,6 +42,26 @@ export function buildDownloadUrl(documentId: string, versionId: string): string
|
|||
return `${config.apiBaseUrl}/api/documents/${documentId}/versions/${versionId}/download`;
|
||||
}
|
||||
|
||||
export interface VersionDownloadLink {
|
||||
url: string;
|
||||
/** ISO-8601 เวลาที่ URL หมดอายุ */
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FR-7 — ขอ presigned URL อายุสั้นของ version หนึ่ง เพื่อเปิดในเบราว์เซอร์ของผู้ใช้
|
||||
* (webview ของ Word ทิ้ง blob download เงียบๆ — ให้เบราว์เซอร์จริงจัดการแทน)
|
||||
*/
|
||||
export async function getDownloadLink(
|
||||
documentId: string,
|
||||
versionId: string
|
||||
): Promise<VersionDownloadLink> {
|
||||
return apiGetJson<VersionDownloadLink>(
|
||||
`/api/documents/${documentId}/versions/${versionId}/download-link`,
|
||||
"ขอลิงก์ดาวน์โหลดไม่สำเร็จ"
|
||||
);
|
||||
}
|
||||
|
||||
/** FR-7 — ดาวน์โหลดไฟล์ของ version หนึ่งเป็น Blob (ต้องแนบ Bearer จึงต้อง fetch แทน link ตรง) */
|
||||
export async function downloadVersion(documentId: string, versionId: string): Promise<Blob> {
|
||||
const response = await apiFetch(buildDownloadUrl(documentId, versionId));
|
||||
|
|
|
|||
|
|
@ -1,45 +1,430 @@
|
|||
import * as React from "react";
|
||||
import Header from "./Header";
|
||||
import HeroList, { HeroListItem } from "./HeroList";
|
||||
import TextInsertion from "./TextInsertion";
|
||||
import { makeStyles } from "@fluentui/react-components";
|
||||
import { Ribbon24Regular, LockOpen24Regular, DesignIdeas24Regular } from "@fluentui/react-icons";
|
||||
import { insertText } from "../taskpane";
|
||||
/* global Word, Office */
|
||||
|
||||
interface AppProps {
|
||||
title: string;
|
||||
}
|
||||
import * as React from "react";
|
||||
import {
|
||||
Button,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
MessageBarActions,
|
||||
Spinner,
|
||||
Text,
|
||||
makeStyles,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { DocumentArrowUp24Regular, Save24Regular, SignOut24Regular } from "@fluentui/react-icons";
|
||||
import * as authService from "../../services/authService";
|
||||
import { AuthRequiredError } from "../../services/apiClient";
|
||||
import { getErrorMessage } from "../../services/errors";
|
||||
import * as metadataService from "../../services/metadataService";
|
||||
import { downloadVersion, getDownloadLink, getVersions } from "../../services/versioningService";
|
||||
import { saveAndVersion, startVersioning } from "../../services/versionFlow";
|
||||
import { VersionRecord } from "../../models/versionRecord";
|
||||
import ConfirmVersionDialog from "./ConfirmVersionDialog";
|
||||
import VersionHistoryPanel from "./VersionHistoryPanel";
|
||||
|
||||
/**
|
||||
* Task pane หลัก — state machine ตาม Phase 5:
|
||||
* checking → (ไม่มี token) signed-out
|
||||
* → (มี token + ไม่ใช่ tracked document) untracked
|
||||
* → (มี token + tracked document) tracking
|
||||
*
|
||||
* กติกา: ทุก async ต้อง catch — AuthRequiredError → กลับ signed-out อย่างเนียน (AC-9)
|
||||
* error อื่นแสดง MessageBar ไม่ปล่อย unhandled rejection
|
||||
*/
|
||||
|
||||
type Phase = "checking" | "signed-out" | "untracked" | "tracking";
|
||||
|
||||
/** FR-3 — ถามเอกสารทุก 30 วินาทีว่ามีแก้ไขที่ยังไม่ได้ save ไหม */
|
||||
const UNSAVED_CHECK_INTERVAL_MS = 30_000;
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: "100vh",
|
||||
padding: tokens.spacingVerticalM,
|
||||
gap: tokens.spacingVerticalS,
|
||||
},
|
||||
header: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingBottom: tokens.spacingVerticalS,
|
||||
borderBottom: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
},
|
||||
section: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: tokens.spacingVerticalS,
|
||||
paddingTop: tokens.spacingVerticalM,
|
||||
},
|
||||
checking: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingVerticalM,
|
||||
paddingTop: tokens.spacingVerticalXL,
|
||||
},
|
||||
});
|
||||
|
||||
const App: React.FC<AppProps> = (props: AppProps) => {
|
||||
const App: React.FC = () => {
|
||||
const styles = useStyles();
|
||||
// The list items are static and won't change at runtime,
|
||||
// so this should be an ordinary const, not a part of state.
|
||||
const listItems: HeroListItem[] = [
|
||||
{
|
||||
icon: <Ribbon24Regular />,
|
||||
primaryText: "Achieve more with Office integration",
|
||||
|
||||
const [phase, setPhase] = React.useState<Phase>("checking");
|
||||
const [bootError, setBootError] = React.useState<string | null>(null);
|
||||
const [username, setUsername] = React.useState<string | null>(null);
|
||||
|
||||
const [documentId, setDocumentId] = React.useState<string | null>(null);
|
||||
const [versions, setVersions] = React.useState<VersionRecord[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = React.useState(false);
|
||||
const [historyError, setHistoryError] = React.useState<string | null>(null);
|
||||
|
||||
const [signingIn, setSigningIn] = React.useState(false);
|
||||
const [loginError, setLoginError] = React.useState<string | null>(null);
|
||||
|
||||
const [starting, setStarting] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [progressMessage, setProgressMessage] = React.useState<string | null>(null);
|
||||
const [confirmOpen, setConfirmOpen] = React.useState(false);
|
||||
const [flowError, setFlowError] = React.useState<string | null>(null);
|
||||
const [flowSuccess, setFlowSuccess] = React.useState<string | null>(null);
|
||||
|
||||
const [downloadingVersionId, setDownloadingVersionId] = React.useState<string | null>(null);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false);
|
||||
|
||||
/** 401 ที่ refresh ไม่ช่วย → กลับหน้า sign-in อย่างเนียน (AC-9) */
|
||||
const handleAuthRequired = React.useCallback((error: unknown): boolean => {
|
||||
if (error instanceof AuthRequiredError) {
|
||||
setPhase("signed-out");
|
||||
setDocumentId(null);
|
||||
setVersions([]);
|
||||
setLoginError("เซสชันหมดอายุ — กรุณา login ใหม่");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const refreshHistory = React.useCallback(
|
||||
async (id: string) => {
|
||||
setHistoryLoading(true);
|
||||
setHistoryError(null);
|
||||
try {
|
||||
setVersions(await getVersions(id));
|
||||
} catch (error) {
|
||||
if (!handleAuthRequired(error)) {
|
||||
setHistoryError(getErrorMessage(error, "โหลด version history ไม่สำเร็จ"));
|
||||
}
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: <LockOpen24Regular />,
|
||||
primaryText: "Unlock features and functionality",
|
||||
},
|
||||
{
|
||||
icon: <DesignIdeas24Regular />,
|
||||
primaryText: "Create and visualize like a pro",
|
||||
},
|
||||
];
|
||||
[handleAuthRequired]
|
||||
);
|
||||
|
||||
/** ตรวจตอนเปิด task pane (FR-1): token → SavedByAddIn → history */
|
||||
const detect = React.useCallback(async () => {
|
||||
setPhase("checking");
|
||||
setBootError(null);
|
||||
try {
|
||||
const token = await authService.getAccessToken();
|
||||
if (!token) {
|
||||
setPhase("signed-out");
|
||||
return;
|
||||
}
|
||||
authService.startAutoRefresh();
|
||||
setUsername(authService.getUsername() ?? null);
|
||||
|
||||
const tracked = await metadataService.isTrackedDocument();
|
||||
if (!tracked) {
|
||||
setPhase("untracked");
|
||||
return;
|
||||
}
|
||||
const properties = await metadataService.readTrackedProperties();
|
||||
const id = properties.documentTrackingId;
|
||||
if (!id) {
|
||||
// mark ว่า track แต่ไม่มี id (ไฟล์เสียหาย) — ให้เริ่มใหม่แบบ untracked
|
||||
setPhase("untracked");
|
||||
return;
|
||||
}
|
||||
setDocumentId(id);
|
||||
setPhase("tracking");
|
||||
await refreshHistory(id);
|
||||
} catch (error) {
|
||||
setBootError(getErrorMessage(error, "ตรวจสอบเอกสารไม่สำเร็จ"));
|
||||
}
|
||||
}, [refreshHistory]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void detect();
|
||||
}, [detect]);
|
||||
|
||||
/** FR-3 — poll `document.saved` เฉพาะตอน tracking */
|
||||
React.useEffect(() => {
|
||||
if (phase !== "tracking") {
|
||||
setHasUnsavedChanges(false);
|
||||
return;
|
||||
}
|
||||
const interval = setInterval(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const unsaved = await Word.run(async (context) => {
|
||||
const document = context.document;
|
||||
document.load("saved");
|
||||
await context.sync();
|
||||
return !document.saved;
|
||||
});
|
||||
setHasUnsavedChanges(unsaved);
|
||||
} catch {
|
||||
// อ่านไม่ได้รอบนี้ก็ข้าม — banner เป็นแค่ soft reminder
|
||||
}
|
||||
})();
|
||||
}, UNSAVED_CHECK_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [phase]);
|
||||
|
||||
const handleSignIn = async () => {
|
||||
setSigningIn(true);
|
||||
setLoginError(null);
|
||||
try {
|
||||
const result = await authService.login();
|
||||
if (result.ok) {
|
||||
await detect();
|
||||
} else {
|
||||
setLoginError(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
setLoginError(getErrorMessage(error, "เข้าสู่ระบบไม่สำเร็จ"));
|
||||
} finally {
|
||||
setSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignOut = () => {
|
||||
authService.signOut();
|
||||
setUsername(null);
|
||||
setDocumentId(null);
|
||||
setVersions([]);
|
||||
setLoginError(null);
|
||||
setPhase("signed-out");
|
||||
};
|
||||
|
||||
/** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) */
|
||||
const handleStartVersioning = async () => {
|
||||
setStarting(true);
|
||||
setFlowError(null);
|
||||
setFlowSuccess(null);
|
||||
try {
|
||||
const result = await startVersioning(setProgressMessage);
|
||||
setDocumentId(result.documentId);
|
||||
setPhase("tracking");
|
||||
setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`);
|
||||
await refreshHistory(result.documentId);
|
||||
} catch (error) {
|
||||
if (!handleAuthRequired(error)) {
|
||||
setFlowError(getErrorMessage(error, "เริ่มติดตามเอกสารไม่สำเร็จ"));
|
||||
}
|
||||
} finally {
|
||||
setProgressMessage(null);
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Save & Version (FR-2) — ตามลำดับ spec: confirm → stamp → XML → save → file+fields → upload */
|
||||
const handleSaveAndVersion = async () => {
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setFlowError(null);
|
||||
setFlowSuccess(null);
|
||||
try {
|
||||
const latestVersionNumber = versions[0]?.versionNumber ?? 0;
|
||||
const result = await saveAndVersion(documentId, latestVersionNumber, setProgressMessage);
|
||||
setFlowSuccess(`สร้าง version ${result.versionNumber} เรียบร้อย`);
|
||||
await refreshHistory(documentId);
|
||||
} catch (error) {
|
||||
if (!handleAuthRequired(error)) {
|
||||
setFlowError(getErrorMessage(error, "สร้าง version ไม่สำเร็จ"));
|
||||
}
|
||||
} finally {
|
||||
setProgressMessage(null);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* FR-4 — ดาวน์โหลด version ที่เลือก: ขอ presigned URL อายุสั้นจาก backend แล้วเปิดใน
|
||||
* เบราว์เซอร์ของเครื่อง (webview ของ Word — โดยเฉพาะ WKWebView บน Mac — ทิ้ง blob download
|
||||
* แบบเงียบๆ ไฟล์ไม่เคยถูกเขียนที่ไหนเลย) · host ไหนไม่มี openBrowserWindow จะย้อนกลับไปใช้ blob
|
||||
*/
|
||||
const handleDownload = async (version: VersionRecord) => {
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
setDownloadingVersionId(version.versionId);
|
||||
setFlowError(null);
|
||||
try {
|
||||
const link = await getDownloadLink(documentId, version.versionId);
|
||||
if (typeof Office.context.ui.openBrowserWindow === "function") {
|
||||
Office.context.ui.openBrowserWindow(link.url);
|
||||
setFlowSuccess(`กำลังดาวน์โหลด v${version.versionNumber} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads`);
|
||||
} else {
|
||||
const blob = await downloadVersion(documentId, version.versionId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `document_v${version.versionNumber}.docx`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!handleAuthRequired(error)) {
|
||||
setFlowError(getErrorMessage(error, "ดาวน์โหลด version ไม่สำเร็จ"));
|
||||
}
|
||||
} finally {
|
||||
setDownloadingVersionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Header logo="assets/logo-filled.png" title={props.title} message="Welcome" />
|
||||
<HeroList message="Discover what this add-in can do for you today!" items={listItems} />
|
||||
<TextInsertion insertText={insertText} />
|
||||
<div className={styles.header}>
|
||||
<Text weight="semibold">e-Court Documents Versioning</Text>
|
||||
{phase !== "signed-out" && phase !== "checking" && username ? (
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<SignOut24Regular />}
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
{username} · ออกจากระบบ
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{phase === "checking" ? (
|
||||
<div className={styles.checking}>
|
||||
<Spinner label="กำลังตรวจสอบเอกสาร…" />
|
||||
{bootError ? (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{bootError}</MessageBarBody>
|
||||
<MessageBarActions>
|
||||
<Button size="small" appearance="outline" onClick={() => void detect()}>
|
||||
ลองใหม่
|
||||
</Button>
|
||||
</MessageBarActions>
|
||||
</MessageBar>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === "signed-out" ? (
|
||||
<div className={styles.section}>
|
||||
<Text size={300}>เข้าสู่ระบบเพื่อเริ่มใช้งาน versioning</Text>
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
ใช้บัญชีของ realm <code>word-versioning</code> — หน้าต่าง login จะเปิดแยกต่างหาก
|
||||
</Text>
|
||||
<Button appearance="primary" disabled={signingIn} onClick={() => void handleSignIn()}>
|
||||
{signingIn ? "กำลังเข้าสู่ระบบ…" : "Sign in"}
|
||||
</Button>
|
||||
{loginError ? (
|
||||
<MessageBar intent="warning">
|
||||
<MessageBarBody>{loginError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === "untracked" ? (
|
||||
<div className={styles.section}>
|
||||
<Text size={300}>เอกสารนี้ยังไม่ได้ติดตาม version</Text>
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1
|
||||
(ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)
|
||||
</Text>
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={<DocumentArrowUp24Regular />}
|
||||
disabled={starting}
|
||||
onClick={() => void handleStartVersioning()}
|
||||
>
|
||||
{starting ? "กำลังเริ่มติดตาม…" : "Start Versioning This Document"}
|
||||
</Button>
|
||||
{progressMessage ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Spinner size="tiny" />
|
||||
<Text size={200}>{progressMessage}</Text>
|
||||
</div>
|
||||
) : null}
|
||||
{flowError ? (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{flowError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === "tracking" ? (
|
||||
<div className={styles.section}>
|
||||
{hasUnsavedChanges ? (
|
||||
<MessageBar intent="warning">
|
||||
<MessageBarBody>
|
||||
มีการแก้ไขที่ยังไม่ได้ version — กด Save & Version เพื่อบันทึกเป็น version ใหม่
|
||||
</MessageBarBody>
|
||||
</MessageBar>
|
||||
) : null}
|
||||
|
||||
{flowSuccess ? (
|
||||
<MessageBar intent="success">
|
||||
<MessageBarBody>{flowSuccess}</MessageBarBody>
|
||||
</MessageBar>
|
||||
) : null}
|
||||
{flowError ? (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{flowError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
appearance="primary"
|
||||
size="large"
|
||||
icon={<Save24Regular />}
|
||||
disabled={saving}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
Save & Version
|
||||
</Button>
|
||||
{saving && progressMessage ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Spinner size="tiny" />
|
||||
<Text size={200}>{progressMessage}</Text>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
tracking id: {documentId?.slice(0, 8)}…
|
||||
</Text>
|
||||
|
||||
<VersionHistoryPanel
|
||||
versions={versions}
|
||||
loading={historyLoading}
|
||||
error={historyError}
|
||||
downloadingVersionId={downloadingVersionId}
|
||||
onDownload={(version) => void handleDownload(version)}
|
||||
onRetry={() => documentId && void refreshHistory(documentId)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ConfirmVersionDialog
|
||||
open={confirmOpen}
|
||||
busy={saving}
|
||||
onConfirm={() => void handleSaveAndVersion()}
|
||||
onDismiss={() => setConfirmOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
55
addin/src/taskpane/components/ConfirmVersionDialog.tsx
Normal file
55
addin/src/taskpane/components/ConfirmVersionDialog.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import * as React from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogSurface,
|
||||
DialogTitle,
|
||||
Spinner,
|
||||
} from "@fluentui/react-components";
|
||||
|
||||
interface ConfirmVersionDialogProps {
|
||||
open: boolean;
|
||||
/** กำลังอยู่ระหว่าง flow หลังกดยืนยันแล้ว — ปุ่นทั้งสอง disable + แสดง spinner */
|
||||
busy: boolean;
|
||||
onConfirm: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
/** FR-2 ขั้นตอนที่ 1 — dialog ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?" */
|
||||
export const ConfirmVersionDialog: React.FC<ConfirmVersionDialogProps> = ({
|
||||
open,
|
||||
busy,
|
||||
onConfirm,
|
||||
onDismiss,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(_event, data) => !data.open && !busy && onDismiss()} modalType="alert">
|
||||
<DialogSurface>
|
||||
<DialogBody>
|
||||
<DialogTitle>สร้าง version ใหม่?</DialogTitle>
|
||||
<DialogContent>
|
||||
ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้
|
||||
{busy ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 12 }}>
|
||||
<Spinner size="tiny" /> กำลังดำเนินการ…
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button appearance="secondary" disabled={busy} onClick={onDismiss}>
|
||||
ยกเลิก
|
||||
</Button>
|
||||
<Button appearance="primary" disabled={busy} onClick={onConfirm}>
|
||||
สร้าง version
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmVersionDialog;
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import * as React from "react";
|
||||
import { Image, tokens, makeStyles } from "@fluentui/react-components";
|
||||
|
||||
export interface HeaderProps {
|
||||
title: string;
|
||||
logo: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
welcome__header: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
paddingBottom: "30px",
|
||||
paddingTop: "100px",
|
||||
backgroundColor: tokens.colorNeutralBackground3,
|
||||
},
|
||||
message: {
|
||||
fontSize: tokens.fontSizeHero900,
|
||||
fontWeight: tokens.fontWeightRegular,
|
||||
fontColor: tokens.colorNeutralBackgroundStatic,
|
||||
},
|
||||
});
|
||||
|
||||
const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
|
||||
const { title, logo, message } = props;
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<section className={styles.welcome__header}>
|
||||
<Image width="90" height="90" src={logo} alt={title} />
|
||||
<h1 className={styles.message}>{message}</h1>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import * as React from "react";
|
||||
import { tokens, makeStyles } from "@fluentui/react-components";
|
||||
|
||||
export interface HeroListItem {
|
||||
icon: React.JSX.Element;
|
||||
primaryText: string;
|
||||
}
|
||||
|
||||
export interface HeroListProps {
|
||||
message: string;
|
||||
items: HeroListItem[];
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
list: {
|
||||
marginTop: "20px",
|
||||
},
|
||||
listItem: {
|
||||
paddingBottom: "20px",
|
||||
display: "flex",
|
||||
},
|
||||
icon: {
|
||||
marginRight: "10px",
|
||||
},
|
||||
itemText: {
|
||||
fontSize: tokens.fontSizeBase300,
|
||||
fontColor: tokens.colorNeutralBackgroundStatic,
|
||||
},
|
||||
welcome__main: {
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
},
|
||||
message: {
|
||||
fontSize: tokens.fontSizeBase500,
|
||||
fontColor: tokens.colorNeutralBackgroundStatic,
|
||||
fontWeight: tokens.fontWeightRegular,
|
||||
paddingLeft: "10px",
|
||||
paddingRight: "10px",
|
||||
},
|
||||
});
|
||||
|
||||
const HeroList: React.FC<HeroListProps> = (props: HeroListProps) => {
|
||||
const { items, message } = props;
|
||||
const styles = useStyles();
|
||||
|
||||
const listItems = items.map((item, index) => (
|
||||
<li className={styles.listItem} key={index}>
|
||||
<i className={styles.icon}>{item.icon}</i>
|
||||
<span className={styles.itemText}>{item.primaryText}</span>
|
||||
</li>
|
||||
));
|
||||
return (
|
||||
<div className={styles.welcome__main}>
|
||||
<h2 className={styles.message}>{message}</h2>
|
||||
<ul className={styles.list}>{listItems}</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroList;
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { Button, Field, Textarea, tokens, makeStyles } from "@fluentui/react-components";
|
||||
|
||||
/* global HTMLTextAreaElement */
|
||||
|
||||
interface TextInsertionProps {
|
||||
insertText: (_text: string) => void;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
instructions: {
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
marginTop: "20px",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
textPromptAndInsertion: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
},
|
||||
textAreaField: {
|
||||
marginLeft: "20px",
|
||||
marginTop: "30px",
|
||||
marginBottom: "20px",
|
||||
marginRight: "20px",
|
||||
maxWidth: "50%",
|
||||
},
|
||||
});
|
||||
|
||||
const TextInsertion: React.FC<TextInsertionProps> = (props: TextInsertionProps) => {
|
||||
const [text, setText] = useState<string>("Some text.");
|
||||
|
||||
const handleTextInsertion = async () => {
|
||||
await props.insertText(text);
|
||||
};
|
||||
|
||||
const handleTextChange = async (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setText(event.target.value);
|
||||
};
|
||||
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<div className={styles.textPromptAndInsertion}>
|
||||
<Field className={styles.textAreaField} size="large" label="Enter text to be inserted into the document.">
|
||||
<Textarea size="large" value={text} onChange={handleTextChange} />
|
||||
</Field>
|
||||
<Field className={styles.instructions}>Click the button to insert text.</Field>
|
||||
<Button appearance="primary" disabled={false} size="large" onClick={handleTextInsertion}>
|
||||
Insert text
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextInsertion;
|
||||
128
addin/src/taskpane/components/VersionHistoryPanel.tsx
Normal file
128
addin/src/taskpane/components/VersionHistoryPanel.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import * as React from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Spinner,
|
||||
Text,
|
||||
makeStyles,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowDownload24Regular, History24Regular } from "@fluentui/react-icons";
|
||||
import { VersionRecord } from "../../models/versionRecord";
|
||||
|
||||
interface VersionHistoryPanelProps {
|
||||
versions: VersionRecord[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** versionId ของ version ที่กำลังดาวน์โหลดอยู่ (แสดง spinner ที่ปุ่ม) */
|
||||
downloadingVersionId: string | null;
|
||||
onDownload: (version: VersionRecord) => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalS,
|
||||
},
|
||||
header: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalXS,
|
||||
marginTop: tokens.spacingVerticalL,
|
||||
marginBottom: tokens.spacingVerticalXXS,
|
||||
},
|
||||
item: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
":hover": {
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||
},
|
||||
},
|
||||
itemInfo: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minWidth: 0,
|
||||
},
|
||||
metaRow: {
|
||||
color: tokens.colorNeutralForeground3,
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
},
|
||||
});
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime())
|
||||
? iso
|
||||
: date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||
}
|
||||
|
||||
/** FR-4 — แสดง version history (เลข version / เวลา / ผู้สร้าง) + ปุ่มดาวน์โหลด version นั้น */
|
||||
export const VersionHistoryPanel: React.FC<VersionHistoryPanelProps> = ({
|
||||
versions,
|
||||
loading,
|
||||
error,
|
||||
downloadingVersionId,
|
||||
onDownload,
|
||||
onRetry,
|
||||
}) => {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
<History24Regular />
|
||||
<Text weight="semibold">Version history</Text>
|
||||
</div>
|
||||
|
||||
{loading ? <Spinner size="small" label="กำลังโหลด…" /> : null}
|
||||
|
||||
{error ? (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>
|
||||
{error}{" "}
|
||||
<Button size="small" appearance="subtle" onClick={onRetry}>
|
||||
ลองใหม่
|
||||
</Button>
|
||||
</MessageBarBody>
|
||||
</MessageBar>
|
||||
) : null}
|
||||
|
||||
{!loading && !error && versions.length === 0 ? (
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
ยังไม่มี version ในระบบ
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{versions.map((version) => (
|
||||
<div className={styles.item} key={version.versionId}>
|
||||
<div className={styles.itemInfo}>
|
||||
<Badge appearance="filled" color="brand">
|
||||
v{version.versionNumber}
|
||||
</Badge>
|
||||
<span className={styles.metaRow}>{formatTimestamp(version.createdAt)}</span>
|
||||
<span className={styles.metaRow}>โดย {version.createdByUserId}</span>
|
||||
</div>
|
||||
<Button
|
||||
icon={<ArrowDownload24Regular />}
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
disabled={downloadingVersionId !== null}
|
||||
onClick={() => onDownload(version)}
|
||||
>
|
||||
{downloadingVersionId === version.versionId ? "กำลังดาวน์โหลด…" : "ดาวน์โหลด"}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VersionHistoryPanel;
|
||||
|
|
@ -5,8 +5,6 @@ import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
|||
|
||||
/* global document, Office, module, require, HTMLElement */
|
||||
|
||||
const title = "Contoso Task Pane Add-in";
|
||||
|
||||
const rootElement: HTMLElement | null = document.getElementById("container");
|
||||
const root = rootElement ? createRoot(rootElement) : undefined;
|
||||
|
||||
|
|
@ -14,7 +12,7 @@ const root = rootElement ? createRoot(rootElement) : undefined;
|
|||
Office.onReady(() => {
|
||||
root?.render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<App title={title} />
|
||||
<App />
|
||||
</FluentProvider>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Contoso Task Pane Add-in</title>
|
||||
<title>e-Court Documents Versioning</title>
|
||||
|
||||
<!-- Office JavaScript API -->
|
||||
<script type="text/javascript" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
/* global Word console */
|
||||
|
||||
export async function insertText(text: string) {
|
||||
// Write text to the document.
|
||||
try {
|
||||
await Word.run(async (context) => {
|
||||
let body = context.document.body;
|
||||
body.insertParagraph(text, Word.InsertLocation.end);
|
||||
await context.sync();
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error: " + error);
|
||||
}
|
||||
}
|
||||
|
|
@ -36,11 +36,17 @@ export function installFetchMock(
|
|||
return fetchMock;
|
||||
}
|
||||
|
||||
/** JWT ปลอมเพื่อ seed session — service อ่านแค่ claim `exp` */
|
||||
export function makeFakeJwt(expiresAtMs: number, subject = "user-1"): string {
|
||||
/** JWT ปลอมเพื่อ seed session — service อ่านแค่ claim `exp` / `sub` / `preferred_username` */
|
||||
export function makeFakeJwt(
|
||||
expiresAtMs: number,
|
||||
subject = "user-1",
|
||||
extraClaims: Record<string, unknown> = {}
|
||||
): string {
|
||||
const encode = (value: string): string =>
|
||||
btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
const header = encode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
||||
const payload = encode(JSON.stringify({ sub: subject, exp: Math.floor(expiresAtMs / 1000) }));
|
||||
const payload = encode(
|
||||
JSON.stringify({ sub: subject, exp: Math.floor(expiresAtMs / 1000), ...extraClaims })
|
||||
);
|
||||
return `${header}.${payload}.${encode("fake-signature")}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,6 @@ if (typeof globalAny.TextEncoder === "undefined") {
|
|||
globalAny.TextDecoder = util.TextDecoder;
|
||||
}
|
||||
|
||||
globalAny.__KEYCLOAK_AUTHORITY__ = "http://localhost:8080/realms/word-versioning";
|
||||
globalAny.__KEYCLOAK_AUTHORITY__ = "https://localhost:8443/realms/word-versioning";
|
||||
globalAny.__KEYCLOAK_CLIENT_ID__ = "word-addin-officejs";
|
||||
globalAny.__API_BASE_URL__ = "http://localhost:5075";
|
||||
globalAny.__API_BASE_URL__ = "https://localhost:7232";
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export interface WordMockState {
|
|||
/** part ทั้งหมดที่ "มีอยู่" ในเอกสารจริงๆ (หลัง delete/add) */
|
||||
xmlParts: Array<{ xml: string; namespace: string }>;
|
||||
contentControls: Array<{ tag: string; text: string }>;
|
||||
/** จำนวนครั้งที่โค้ดเรียก context.document.save() */
|
||||
saveCalls: number;
|
||||
}
|
||||
|
||||
export function installWordMock(options: WordMockOptions = {}): WordMockState {
|
||||
|
|
@ -34,6 +36,7 @@ export function installWordMock(options: WordMockOptions = {}): WordMockState {
|
|||
// part ที่ป้อนเข้า mock ถือว่าเป็นของ namespace ของเรา (จะเขียนทับ/delete โดย service)
|
||||
xmlParts: (options.xmlParts ?? []).map((xml) => ({ xml, namespace: CUSTOM_XML_NAMESPACE })),
|
||||
contentControls: (options.contentControls ?? []).map((control) => ({ ...control })),
|
||||
saveCalls: 0,
|
||||
};
|
||||
|
||||
const makeProperty = (key: string): unknown => {
|
||||
|
|
@ -93,6 +96,11 @@ export function installWordMock(options: WordMockOptions = {}): WordMockState {
|
|||
},
|
||||
// ใน Word จริง customXmlParts อยู่บน context.document (Api set 1.4)
|
||||
customXmlParts,
|
||||
save: (_saveBehavior?: unknown) => {
|
||||
state.saveCalls++;
|
||||
},
|
||||
/** FR-3 poll อ่านค่านี้ — default ไม่มีแก้ไขค้าง */
|
||||
saved: true,
|
||||
contentControls: {
|
||||
get items() {
|
||||
return state.contentControls.map((control) => ({ tag: control.tag, text: control.text }));
|
||||
|
|
@ -107,6 +115,7 @@ export function installWordMock(options: WordMockOptions = {}): WordMockState {
|
|||
(globalThis as unknown as Record<string, unknown>).Word = {
|
||||
run: async <T>(batch: (context: unknown) => Promise<T>): Promise<T> =>
|
||||
batch(context as unknown as Word.RequestContext),
|
||||
SaveBehavior: { save: "Save", prompt: "Prompt" },
|
||||
} as unknown as typeof Word;
|
||||
|
||||
// กันชัดว่า mock ตรงกับ namespace ที่ service ใช้
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
|
|
|
|||
|
|
@ -9,9 +9,12 @@ const urlDev = "https://localhost:3000/";
|
|||
const urlProd = "https://www.contoso.com/"; // CHANGE THIS TO YOUR PRODUCTION DEPLOYMENT LOCATION
|
||||
|
||||
// config ที่ inject เข้าโค้ดตอน build (อ่านจาก env — ห้าม hardcode ตามกติกา) — default คือ local dev
|
||||
const keycloakAuthority = process.env.KEYCLOAK_AUTHORITY || "http://localhost:8080/realms/word-versioning";
|
||||
// Keycloak ต้องเป็น HTTPS เสมอ: Office Dialog API ปฏิเสธ http:// ("The HTTP protocol is not supported")
|
||||
const keycloakAuthority =
|
||||
process.env.KEYCLOAK_AUTHORITY || "https://localhost:8443/realms/word-versioning";
|
||||
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID || "word-addin-officejs";
|
||||
const apiBaseUrl = (process.env.API_BASE_URL || "http://localhost:5075").replace(/\/$/, "");
|
||||
// backend ต้องเป็น HTTPS ด้วยเหมือนกัน — webview ของ Word บล็อก fetch http:// จากหน้า https (mixed content)
|
||||
const apiBaseUrl = (process.env.API_BASE_URL || "https://localhost:7232").replace(/\/$/, "");
|
||||
|
||||
async function getHttpsOptions() {
|
||||
const httpsOptions = await devCerts.getHttpsServerOptions();
|
||||
|
|
|
|||
|
|
@ -400,6 +400,33 @@ public class DocumentsController(
|
|||
return File(stream, DocxContentType, downloadName);
|
||||
}
|
||||
|
||||
/// <summary>FR-7 (ทางเลือก presigned URL อายุสั้น — spec ส่วน 9 อนุญาต)
|
||||
/// ออก URL ให้ add-in เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง — webview ของ Word (โดยเฉพาะ WKWebView บน Mac)
|
||||
/// ทิ้ง blob download แบบเงียบๆ จึงต้องให้เบราว์เซอร์จริงจัดการการดาวน์โหลดแทน</summary>
|
||||
[HttpGet("{documentId:guid}/versions/{versionId:guid}/download-link")]
|
||||
[ProducesResponseType(typeof(VersionDownloadLinkResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<VersionDownloadLinkResponse>> GetVersionDownloadLink(
|
||||
Guid documentId, Guid versionId, CancellationToken ct)
|
||||
{
|
||||
var objectKey = await db.DocumentVersions
|
||||
.AsNoTracking()
|
||||
.Where(v => v.DocumentId == documentId && v.Id == versionId)
|
||||
.Select(v => v.ObjectKey)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (objectKey is null)
|
||||
return NotFound($"ไม่พบ version {versionId} ของ document {documentId}");
|
||||
|
||||
var expirySeconds = Math.Clamp(minioOptions.Value.PresignedExpirySeconds, 30, 600);
|
||||
var url = storage.GetPresignedGetUrl(minioOptions.Value.VersionsBucket, objectKey, TimeSpan.FromSeconds(expirySeconds));
|
||||
|
||||
logger.LogInformation(
|
||||
"Version download link issued documentId={DocumentId} versionId={VersionId} userId={UserId} expirySeconds={ExpirySeconds}",
|
||||
documentId, versionId, currentUser.UserId, expirySeconds);
|
||||
|
||||
return Ok(new VersionDownloadLinkResponse(url, DateTimeOffset.UtcNow + TimeSpan.FromSeconds(expirySeconds)));
|
||||
}
|
||||
|
||||
/// <summary>ถอนการเปลี่ยน DocumentFieldValue ที่ stage ไว้กลับเป็นค่าก่อน request
|
||||
/// — ใช้ตอน MinIO fail หลัง SaveChanges แล้ว (ไม่ให้ค่าล้ำหน้า version ที่ถูกถอน)</summary>
|
||||
private static void RevertStagedFieldChanges(
|
||||
|
|
|
|||
|
|
@ -39,3 +39,6 @@ public record VersionListItem(
|
|||
string CreatedByUserId,
|
||||
string ContentHash,
|
||||
object? Fields);
|
||||
|
||||
/// <summary>response ของ GET .../download-link — presigned URL อายุสั้นให้เปิดในเบราว์เซอร์ของผู้ใช้</summary>
|
||||
public record VersionDownloadLinkResponse(string Url, DateTimeOffset ExpiresAt);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.OpenApi.Models;
|
||||
|
|
@ -69,6 +71,32 @@ builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => poli
|
|||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()));
|
||||
|
||||
// --- dev HTTPS -------------------------------------------------------------
|
||||
// Word webview (WKWebView บน Mac) บล็อก fetch จากหน้า https ไป http แบบเงียบๆ ("Load failed")
|
||||
// ทั้ง task pane เป็น https://localhost:3000 อยู่แล้ว — จึงต้องมี endpoint https ให้ API ด้วย
|
||||
// ใช้ cert ชุดเดียวกับ Keycloak ใน docker-compose (office-addin-dev-certs — CA ถูก trust ในเครื่อง)
|
||||
// เปิดเฉพาะตอน Environment=Development และเจอไฟล์ cert (ปิดผ่าน DevHttps:Enabled=false หรือรัน Production)
|
||||
// หมายเหตุ: เมื่อ Listen ตรงนี้ URL จาก launchSettings/ASPNETCORE_URLS จะถูกแทนที่ — พอร์ตอ่านจาก config
|
||||
var devHttpsEnabled = builder.Configuration.GetValue<bool?>("DevHttps:Enabled") ?? builder.Environment.IsDevelopment();
|
||||
if (devHttpsEnabled)
|
||||
{
|
||||
var certDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".office-addin-dev-certs");
|
||||
var certPath = builder.Configuration["DevHttps:CertificatePath"] ?? Path.Combine(certDirectory, "localhost.crt");
|
||||
var keyPath = builder.Configuration["DevHttps:CertificateKeyPath"] ?? Path.Combine(certDirectory, "localhost.key");
|
||||
if (File.Exists(certPath) && File.Exists(keyPath))
|
||||
{
|
||||
var certificate = LoadCertificateWithPrivateKey(certPath, keyPath);
|
||||
var httpPort = builder.Configuration.GetValue("DevHttps:HttpPort", 5075);
|
||||
var httpsPort = builder.Configuration.GetValue("DevHttps:HttpsPort", 7232);
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.ListenLocalhost(httpPort);
|
||||
options.ListenLocalhost(httpsPort, listenOptions => listenOptions.UseHttps(certificate));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
|
|
@ -83,3 +111,15 @@ app.UseAuthorization();
|
|||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>โหลด cert PEM + private key เป็น X509Certificate2 พร้อมใช้กับ Kestrel
|
||||
/// (key ของ office-addin-dev-certs เป็น PKCS#1 ซึ่ง overload UseHttps(path, path) ไม่รับ — ต้องโหลดเอง)</summary>
|
||||
static X509Certificate2 LoadCertificateWithPrivateKey(string certPath, string keyPath)
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(File.ReadAllText(keyPath)); // รองรับทั้ง PKCS#1 และ PKCS#8
|
||||
var leaf = X509Certificate2.CreateFromPem(File.ReadAllText(certPath));
|
||||
using var paired = leaf.CopyWithPrivateKey(rsa);
|
||||
// macOS: SslStream ใช้ key ได้ต่อเมื่อ cert ถูก export กลับมาเป็น PFX แบบ in-memory
|
||||
return new X509Certificate2(paired.Export(X509ContentType.Pfx));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,4 +18,7 @@ public class MinioOptions
|
|||
public string VersionsBucket { get; set; } = "docx-versions";
|
||||
|
||||
public string TemplatesBucket { get; set; } = "docx-templates";
|
||||
|
||||
/// <summary>อายุของ presigned download URL ที่ออกให้เบราว์เซอร์ (วินาที) — สั้นๆ ตาม spec</summary>
|
||||
public int PresignedExpirySeconds { get; set; } = 120;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,17 +12,23 @@ public interface IObjectStorageService
|
|||
|
||||
/// <summary>ดึง object กลับมาเป็น stream (proxy ต่อให้ client) — stream นี้ dispose แล้วจะปิด S3 response ให้ด้วย</summary>
|
||||
Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default);
|
||||
|
||||
/// <summary>สร้าง presigned GET URL อายุสั้น — ให้เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง
|
||||
/// (webview ของ Word ทิ้ง blob download แบบเงียบๆ — spec อนุญาต presigned URL อายุสั้นสำหรับดาวน์โหลด)</summary>
|
||||
string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry);
|
||||
}
|
||||
|
||||
/// <summary>Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API)</summary>
|
||||
public class ObjectStorageService : IObjectStorageService
|
||||
{
|
||||
private readonly IAmazonS3 _s3;
|
||||
private readonly bool _useHttps;
|
||||
private readonly ILogger<ObjectStorageService> _logger;
|
||||
|
||||
public ObjectStorageService(IOptions<MinioOptions> options, ILogger<ObjectStorageService> logger)
|
||||
{
|
||||
var opt = options.Value;
|
||||
_useHttps = opt.UseSSL;
|
||||
var config = new AmazonS3Config
|
||||
{
|
||||
ServiceURL = (opt.UseSSL ? "https://" : "http://") + opt.Endpoint,
|
||||
|
|
@ -70,6 +76,27 @@ public class ObjectStorageService : IObjectStorageService
|
|||
return new S3ResponseStream(response);
|
||||
}
|
||||
|
||||
public string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry)
|
||||
{
|
||||
var request = new GetPreSignedUrlRequest
|
||||
{
|
||||
BucketName = bucket,
|
||||
Key = key,
|
||||
Verb = HttpVerb.GET,
|
||||
Expires = DateTime.UtcNow + expiry,
|
||||
};
|
||||
// GetPreSignedURL เป็น sync (เซ็น SigV4 ฝั่ง client ไม่ยิง network)
|
||||
var url = _s3.GetPreSignedURL(request);
|
||||
// AWSSDK v4 บังคับ https ใน presigned URL เสมอ (ไม่สน UseHttp) — สลับ scheme เองตาม config ได้
|
||||
// เพราะ SigV4 เซ็นค่า host header (host:port) ไม่รวม scheme → signature ยังถูกต้อง
|
||||
if (!_useHttps && url.StartsWith("https://", StringComparison.Ordinal))
|
||||
{
|
||||
url = "http://" + url["https://".Length..];
|
||||
}
|
||||
_logger.LogInformation("Issued presigned GET {Bucket}/{Key} expiry={Expiry}s", bucket, key, expiry.TotalSeconds);
|
||||
return url;
|
||||
}
|
||||
|
||||
/// <summary>Stream ที่ครอบ GetObjectResponse — dispose stream = dispose response ทั้งอัน
|
||||
/// (ไม่งั้น controller ต้องรับ response มาด้วยแล้วจัดการ lifetime เอง ยุ่ง)</summary>
|
||||
private sealed class S3ResponseStream(GetObjectResponse response) : Stream
|
||||
|
|
|
|||
|
|
@ -15,5 +15,12 @@
|
|||
"MinIO": {
|
||||
"AccessKey": "wordversioning-minio",
|
||||
"SecretKey": "dev-local-pass"
|
||||
},
|
||||
|
||||
"DevHttps": {
|
||||
"//": "endpoint https ของ dev (Word webview บล็อก http จากหน้า https) — cert จาก ~/.office-addin-dev-certs",
|
||||
"Enabled": true,
|
||||
"HttpPort": 5075,
|
||||
"HttpsPort": 7232
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
},
|
||||
|
||||
"Authentication": {
|
||||
"Authority": "http://localhost:8080/realms/word-versioning",
|
||||
"Authority": "https://localhost:8443/realms/word-versioning",
|
||||
"ValidAudiences": [ "word-addin-officejs", "word-versioning-web" ]
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -77,10 +77,16 @@ services:
|
|||
environment:
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME}
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD}
|
||||
# HTTPS ด้วย cert ของ office-addin-dev-certs (CA ถูก trust ใน keychain แล้ว)
|
||||
# จำเป็นเพราะ Office Dialog API บังคับ HTTPS สำหรับหน้า login — http:// โดนปฏิเสธ
|
||||
KC_HTTPS_CERTIFICATE_FILE: /certs/localhost.crt
|
||||
KC_HTTPS_CERTIFICATE_KEY_FILE: /certs/localhost.key
|
||||
ports:
|
||||
- "8080:8080" # admin console + OIDC endpoints
|
||||
- "8443:8443" # OIDC endpoints + admin console (HTTPS — ใช้ตัวนี้)
|
||||
# 8080 (http) เปิดไว้เฉพาะใน network ของ compose ให้ service `api` (profile) ใช้ภายใน
|
||||
volumes:
|
||||
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
|
||||
- ${HOME}/.office-addin-dev-certs:/certs:ro
|
||||
|
||||
# --- Backend API (ยังไม่เปิดใช้ — รอ Phase 2 สร้าง Dockerfile) -----
|
||||
# รันฝั่ง host ด้วย `dotnet run` ได้เลย โดยชี้ config ไปที่ localhost:5432 / localhost:9000
|
||||
|
|
|
|||
|
|
@ -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–4 เสร็จ (scaffold + infra + backend ครบ + add-in services layer) — ต่อไป: phase 5 (add-in UI) หรือ phase 6 (web) ทำขนานกันได้
|
||||
> สถานะ: Phase 0–5 เสร็จ (backend ครบ + add-in ครบทั้ง services และ UI — เหลือตรวจ sideload บน Word จริง) — ต่อไป: phase 6 (web portal)
|
||||
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
|
||||
|
||||
---
|
||||
|
|
@ -51,24 +51,32 @@
|
|||
/word-addin
|
||||
/addin # Phase 0, 4, 5
|
||||
manifest.xml
|
||||
eslint.config.mjs # office-addin-lint ใช้ไฟล์นี้แทน config ของมัน (ปิด rule ที่ crash)
|
||||
jest.config.js
|
||||
/src
|
||||
/taskpane
|
||||
taskpane.html
|
||||
taskpane.tsx
|
||||
auth-callback.html
|
||||
index.tsx # Office.onReady → FluentProvider + App
|
||||
auth-callback.html/.ts # redirect target ของ Keycloak (รันใน Office Dialog)
|
||||
/components
|
||||
App.tsx # state machine: checking → signed-out → untracked → tracking
|
||||
VersionHistoryPanel.tsx
|
||||
ConfirmVersionDialog.tsx
|
||||
/services
|
||||
authService.ts # PKCE + Dialog API + token in-memory + silent refresh
|
||||
metadataService.ts # custom properties + custom XML part + content controls
|
||||
versioningService.ts
|
||||
fileService.ts
|
||||
authService.ts
|
||||
apiClient.ts
|
||||
fileService.ts # getFileAsync → slices → Blob (normalize slice.data ทุกรูปแบบ)
|
||||
apiClient.ts # fetch + Bearer + retry-on-401 ครั้งเดียว
|
||||
versioningService.ts # uploadVersion / getVersions / download + download-link
|
||||
versionFlow.ts # orchestration ของ Start Versioning / Save & Version (7 ขั้น)
|
||||
errors.ts
|
||||
/models
|
||||
versionRecord.ts
|
||||
/test # wordMock / httpMock / setup (jsdom polyfills)
|
||||
package.json
|
||||
webpack.config.js
|
||||
webpack.config.js # DefinePlugin: KEYCLOAK_AUTHORITY / API_BASE_URL จาก env
|
||||
/scripts
|
||||
dev-token.py # เดิน PKCE จริงได้ token ทดสอบ backend (admin-cli ไม่มี aud)
|
||||
/backend # Phase 0, 2, 3
|
||||
/WordVersioning.Api
|
||||
Program.cs
|
||||
|
|
@ -344,13 +352,13 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
|
|||
|
||||
**เป้า:** UI ครบทุก flow — ตรง spec ส่วน 11 (frontend ข้อ 4)
|
||||
|
||||
- [ ] `taskpane.tsx` state machine หลัก: `checking → signed-out → untracked → tracking`
|
||||
- [ ] **Sign-in gate (FR-1):** `Office.onReady()` → เช็ค token ก่อน — ไม่มี/หมดอายุ → หน้า "Sign in" (ปุ่มเรียก authService); มี → เช็ค property `SavedByAddIn` เทียบ `ADDIN_SIGNATURE_GUID`
|
||||
- [x] `taskpane.tsx` state machine หลัก: `checking → signed-out → untracked → tracking`
|
||||
- [x] **Sign-in gate (FR-1):** `Office.onReady()` → เช็ค token ก่อน — ไม่มี/หมดอายุ → หน้า "Sign in" (ปุ่มเรียก authService); มี → เช็ค property `SavedByAddIn` เทียบ `ADDIN_SIGNATURE_GUID`
|
||||
- ตรง → อ่าน `DocumentTrackingId` → `getVersions()` → เข้าสถานะ `tracking` (**ก็คือ flow ของไฟล์ที่สร้างจากเว็บ — เปิดปุ๊บเห็น v1 ทันที ไม่ต้อง Start Versioning**)
|
||||
- ไม่ตรง → ปุ่ม **"Start Versioning This Document"**
|
||||
- [ ] **Start Versioning:** stamp metadata รอบแรก (`SavedByAddIn`, `DocumentTrackingId` ใหม่) → upload version แรก → สลับเป็น `tracking`
|
||||
- [ ] `ConfirmVersionDialog.tsx` — ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?"
|
||||
- [ ] **Save & Version flow (FR-2) ตามลำดับ spec แบบเคร่งครัด:**
|
||||
- [x] **Start Versioning:** stamp metadata รอบแรก (`SavedByAddIn`, `DocumentTrackingId` ใหม่) → upload version แรก → สลับเป็น `tracking`
|
||||
- [x] `ConfirmVersionDialog.tsx` — ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?"
|
||||
- [x] **Save & Version flow (FR-2) ตามลำดับ spec แบบเคร่งครัด:**
|
||||
1. เปิด confirm dialog
|
||||
2. Stamp properties: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`
|
||||
3. อัปเดต XML part (delete-then-add, fail ได้แบบ silent)
|
||||
|
|
@ -359,13 +367,29 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
|
|||
6. `POST /api/documents/{documentId}/versions` — แนบ `fields` ด้วย
|
||||
7. แสดงผล + refresh history
|
||||
- ทุก step มี loading state + error message + **ห้ามปล่อย unhandled rejection**
|
||||
- [ ] `VersionHistoryPanel.tsx` (FR-4) — list version number/timestamp/ผู้สร้าง + ปุ่ม "Download this version" (`GET .../download`)
|
||||
- [ ] **Soft reminder (FR-3):** polling/debounce ทุก ~30 วินาทีเช็ค `context.document.saved` — `false` ต่อเนื่องเกิน threshold → banner "มีการแก้ไขที่ยังไม่ได้ version" (ไม่ block ใคร)
|
||||
- [ ] จัดการ token หมดอายุกลาง session: 401 → silent refresh → ยังไม่ได้ → กลับหน้า sign-in อย่างเนียน (AC-9)
|
||||
- [ ] Fluent UI components ให้หน้าตาเข้ากับ Office
|
||||
- [x] `VersionHistoryPanel.tsx` (FR-4) — list version number/timestamp/ผู้สร้าง + ปุ่ม "Download this version" (`GET .../download`)
|
||||
- [x] **Soft reminder (FR-3):** polling/debounce ทุก ~30 วินาทีเช็ค `context.document.saved` — `false` ต่อเนื่องเกิน threshold → banner "มีการแก้ไขที่ยังไม่ได้ version" (ไม่ block ใคร)
|
||||
- [x] จัดการ token หมดอายุกลาง session: 401 → silent refresh → ยังไม่ได้ → กลับหน้า sign-in อย่างเนียน (AC-9)
|
||||
- [x] Fluent UI components ให้หน้าตาเข้ากับ Office
|
||||
|
||||
หมายเหตุจากการทำจริง (2026-08-19):
|
||||
- **state machine อยู่ที่ `src/taskpane/components/App.tsx`** (yo office ให้โครง index.tsx + App.tsx — ไม่มี taskpane.tsx ตามชื่อในแผน) และ orchestration ของ Start Versioning / Save & Version แยกเป็น `services/versionFlow.ts` เพื่อ unit test ได้ (mock ทั้ง Word/Office/fetch) — `npm test` ขยายเป็น 45 ตัว
|
||||
- **`LastVersionId` ในไฟล์ = `clientVersionId`** (GUID ที่ client generate) ไม่ใช่ versionId ของ backend — เพราะต้อง stamp ลง properties *ก่อน* `document.save()` จึงจะไปอยู่ในไฟล์ที่อัปโหลด (backend versionId ได้มาหลังไฟล์ถูก capture ไปแล้ว) · ส่วน entry ใน XML part เดาเลข version จาก history ล่าสุด+1 ก่อน upload ถ้า backend ตอบเลขต่าง (มี client อื่นแทรก) จะเขียน XML part แก้ให้ตรงแบบ best-effort
|
||||
- **เปิด `strict` ใน tsconfig ของ addin** — พบว่า tsconfig ของ scaffold ปิด strictNullChecks ซึ่งทำให้ boolean-literal discriminated union (`{ok:true} | {ok:false; error}`) **narrow ไม่ได้** (compile error) — เปิดแล้วมี error เพิ่มแค่ 4 จุดใน test (แก้แล้ว)
|
||||
- ชื่อไฟล์ตอน upload ได้จาก `Office.context.document.url` (desktop มักมี path จริง) fallback `{documentId}.docx` (Word Online อาจไม่มี URL)
|
||||
- FR-3: poll ทุก 30 วิ **เฉพาะ phase `tracking`** อ่าน `context.document.saved` — fail รอบใดข้ามรอบนั้น (banner เป็น soft reminder เท่านั้น)
|
||||
- ดาวน์โหลด version: fetch เป็น blob (ต้องแนบ Bearer) → `URL.createObjectURL` + anchor `download` — ชื่อไฟล์ `document_v{N}.docx`
|
||||
- ลบ scaffold demo ออก (Header/HeroList/TextInsertion/taskpane.ts) — task pane เป็นหน้าจริงของระบบแล้ว
|
||||
- ⚠️ **DoD ส่วน sideload ต้องตรวจด้วยมือใน Word จริง** (Claude รัน Word ไม่ได้) — ขั้นตอน: รัน infra + backend + `npm start` แล้ว sideload manifest.xml บน Word Desktop/Online แล้วไล่ checklist AC-1, 2, 4–8, 13 (ส่วนที่ automate ได้คือ build/test/lint/validate ซึ่งผ่านทั้งหมดแล้ว)
|
||||
- 🩹 **แก้จาก sideload test จริงรอบแรก:** กด Sign in แล้วโดน "The HTTP protocol is not supported. Use HTTPS instead" — **Office Dialog API ปฏิเสธ http:// โดยสมบูรณ์** ทั้งที่ Keycloak dev เดิมรัน http://localhost:8080 → แก้โดยให้ Keycloak เสิร์ฟ **HTTPS ที่ 8443** ด้วย cert ของ `office-addin-dev-certs` (mount `${HOME}/.office-addin-dev-certs` เข้า container + `KC_HTTPS_CERTIFICATE_FILE/KEY_FILE`) · 8080 เหลือไว้เฉพาะภายใน network ของ compose (ให้ service `api` ใช้) · ค่าที่ตามไปเปลี่ยน: backend `Authentication:Authority` → `https://localhost:8443/realms/word-versioning`, add-in webpack default `KEYCLOAK_AUTHORITY` → เดียวกัน, README/.env.example/test constants ทั้งหมด · ตรวจจริงครบ: discovery ผ่าน (issuer = https…8443), PKCE flow จริงได้ token ที่มี `aud` ถูกต้อง, backend ตอบ 200 · **ผลพลอยได้:** เจอว่าตัวอย่าง curl admin-cli เดิมเรียก backend ไม่ผ่าน (token ไม่มี `aud`) → เพิ่ม `scripts/dev-token.py` (เดิน PKCE จริง ได้ token ใช้ทดสอบ backend/Swagger) และแก้ README ให้ใช้แทน
|
||||
- 🩹 **แก้จาก sideload test จริงรอบสี่:** กดดาวน์โหลด version แล้ว**ไฟล์ไม่ไปไหนเลย** (ค้น ~/Downloads + Word container + Spotlight ไม่เจอ) — **WKWebView ของ Word ทิ้ง blob download (`a[download]` + object URL) แบบเงียบๆ** → เปลี่ยนวิธีเป็น "ขอ presigned URL อายุสั้นแล้วเปิดในเบราว์เซอร์ของเครื่อง": backend เพิ่ม `GET .../download-link` (MinIO presigned GET · อายุ config `MinIO:PresignedExpirySeconds` default 120s clamp 30–600 — spec ส่วน 9 อนุญาต presigned URL อายุสั้น) · add-in ใช้ `Office.context.ui.openBrowserWindow` (manifest เพิ่ม `OpenBrowserWindowApi 1.1`) โดยมี blob เดิมเป็น fallback บน host ที่ไม่มี API นี้ · จุดเจาะจง: **AWSSDK.S3 v4 บังคับ https ใน presigned URL เสมอ** (พิสูจน์ด้วยโปรเจกต์ทดสอบแยก — UseHttp ไม่ช่วย) แต่ SigV4 เซ็นค่า host header ไม่รวม scheme จึงสลับ https→http หลังเซ็นตาม config `UseSSL` ได้ · ตรวจจริง: upload → ขอ link → curl presigned URL ได้ไบต์ตรงเป๊ะ ✓ · อื่นๆ รอบนี้: **ลบ `manifest.json` ทิ้ง** (unified manifest ฝั่ง Outlook จาก scaffold — ไม่ได้ใช้ แก้แล้วไม่มีผลกับ Word) และเปลี่ยนชื่อเป็น **e-Court Documents Versioning** ทั้งใน manifest.xml (DisplayName/group/ปุ่ม) และหัว task pane · ⚠️ **อย่า `cp` ทับ manifest ใน wef folder** — office-addin-debugging สร้าง symlink ไว้ การ cp ทับทำให้กลายเป็นไฟล์ธรรมดา แล้ว `npm start` ครั้งถัดไปจะ EEXIST — ให้แก้ที่ `addin/manifest.xml` ต้นทางแล้ว restart Word
|
||||
- 🩹 **แก้จาก sideload test จริงรอบสาม:** เข้า state tracking ได้แล้ว แต่ทุก API call โดน "Load failed" — **WKWebView ของ Word บน Mac บล็อก fetch จากหน้า https ไป http แบบเงียบๆ (mixed content/ATS)**: task pane เป็น `https://localhost:3000` ส่วน API เดิมเป็น `http://localhost:5075` (login ผ่านเพราะ Keycloak เป็น https แล้ว · CORS ตรวจแล้วสมบูรณ์ ไม่ใช่ต้นเหตุ) → แก้ด้วยวิธีเดียวกับ Keycloak: **backend เปิด endpoint `https://localhost:7232` ด้วย dev cert ชุดเดียวกัน** — `Program.cs` เพิ่ม `ConfigureKestrel` (เปิดเฉพาะ Development + เจอ cert, config `DevHttps:*` ใน appsettings.Development.json, Listen ทั้ง 5075/7232 แทน URL จาก launchSettings) · จุดเจาะจง: key ของ office-addin-dev-certs เป็น **PKCS#1** ซึ่ง `UseHttps(cert, key)` overload ไม่รับ → โหลดเองด้วย `RSA.ImportFromPem` + `CopyWithPrivateKey` + export PFX in-memory (macOS ต้องทำแบบนี้ SslStream ถึงใช้ key ได้) · add-in `API_BASE_URL` default → `https://localhost:7232` · ตรวจจริง: รัน temp instance แล้ว curl ผ่าน https ด้วย dev-token.py ได้ 200 · **สรุปกติกา dev: ทุก origin ที่หน้า https จะเรียก (Keycloak 8443, backend 7232) ต้องเป็น https ด้วย cert จาก office-addin-dev-certs ทั้งหมด**
|
||||
- 🩹 **แก้จาก sideload test จริงรอบสอง:** login ผ่านแล้ว กด Start Versioning โดน "รูปแบบข้อมูลของ slice ไม่รองรับ (ต้องเป็น byte array)" — **host แต่ละตัวส่ง `slice.data` กลับคนละรูป** (เอกสารระบุลอยๆ ว่า "byte array"): Word Win = ArrayBuffer/view แต่ **Word Mac ส่งมาเป็นอย่างอื่น** (base64 string / number[]) → `sliceDataToBytes()` ใน fileService รองรับครบทั้ง 4 รูปแบบ + เติม test ครบทุกรูป (47 ตัว) · อีกเรื่องที่เจอ: restart `npm start` ไม่ได้แทน dev-server ตัวเก่า (port 3000 ถูกถือครอง → ตัวเก่ายัง serve bundle เก่า) — ต้อง kill ตัวเก่าก่อนเสมอ · และ webpack **ไม่ restart เองเมื่อ webpack.config.js เปลี่ยน** (DefinePlugin ค่าเก่าติดไปกับ bundle เก่า) — แก้ config แล้วต้อง restart dev-server ทุกครั้ง
|
||||
|
||||
**DoD:** sideload บน Word Desktop (Mac ที่มีอยู่ + Windows ถ้ามี) และ Word Online → ครบ flow: login ผ่าน dialog, start versioning, save & version, เห็น history, download ไฟล์ย้อนหลังเปิดได้ปกติ (AC-1, 2, 4, 5, 6, 7, 8 ผ่าน) + แก้ค่าใน content control แล้ว Save & Version → DB field values เปลี่ยนตาม (AC-13) + เปิดไฟล์ที่ generate จากเว็บ (Phase 6 เสร็จ) → เห็นเป็น tracked + v1 ทันที (AC-12)
|
||||
|
||||
> 🟡 implement + automated checks ผ่านหมด (2026-08-19): `npm run build` compiled successfully, `npm test` 47/47 (versionFlow + fileService ครบทุกรูปแบบ slice), `npm run lint` + `npm run validate` ผ่าน · **sideload ทดสอบจริงบน Word for Mac แล้วบางส่วน**: login ผ่าน dialog ✓ · ตรวจจับ + เข้า state tracking ✓ · Start Versioning / Save & Version ✓ — เจอปัญหาตกมุม 4 รอบและแก้หมดแล้ว (ดู 🩹 ด้านล่าง) · **ยังเหลือ**: ยืนยันการดาวน์โหลดผ่าน presigned link หลังแก้รอบล่าสุด, ไล่ AC เต็มชุด (โดยเฉพาะ AC-9/10/13) และทดสอบบน Word Online/Windows
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Web Portal — FR-9 (UI), FR-10 (UI) *(ใหม่)*
|
||||
|
|
|
|||
114
scripts/dev-token.py
Executable file
114
scripts/dev-token.py
Executable file
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env python3
|
||||
"""ขอ access token แบบ PKCE (authorization code flow) จาก Keycloak dev — สำหรับทดสอบ backend ผ่าน curl/Swagger.
|
||||
|
||||
ทำไมต้องมีสคริปต์นี้: ทุก client ใน realm ปิด direct grants บังคับ PKCE ตาม spec
|
||||
ส่วน token ของ `admin-cli` ไม่มี claim `aud` จึงเรียก backend ไม่ผ่าน (401)
|
||||
|
||||
วิธีใช้:
|
||||
python3 scripts/dev-token.py # ปริ้น access token
|
||||
python3 scripts/dev-token.py --clip # คัดลอกเข้า clipboard (macOS)
|
||||
|
||||
แก้ user/password ผ่าน env: DEV_TOKEN_USERNAME / DEV_TOKEN_PASSWORD (default testuser/Test1234!)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
REALM = "https://localhost:8443/realms/word-versioning"
|
||||
CLIENT_ID = "word-versioning-web"
|
||||
REDIRECT_URI = "http://localhost:5173/callback" # ไม่ต้องมี server จริง — แค่ต้องตรงกับที่ลงทะเบียนไว้
|
||||
CA_FILE = os.path.expanduser("~/.office-addin-dev-certs/ca.crt")
|
||||
|
||||
USERNAME = os.environ.get("DEV_TOKEN_USERNAME", "testuser")
|
||||
PASSWORD = os.environ.get("DEV_TOKEN_PASSWORD", "Test1234!")
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""หยุดที่ 302 เพื่อเก็บ authorization code จาก Location"""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ARG002
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--clip", action="store_true", help="คัดลอก token เข้า clipboard (pbcopy)")
|
||||
args = parser.parse_args()
|
||||
|
||||
verifier = base64.urlsafe_b64encode(os.urandom(48)).rstrip(b"=").decode()
|
||||
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||||
|
||||
ctx = ssl.create_default_context(cafile=CA_FILE)
|
||||
opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),
|
||||
NoRedirect,
|
||||
urllib.request.HTTPSHandler(context=ctx),
|
||||
)
|
||||
|
||||
auth_url = f"{REALM}/protocol/openid-connect/auth?" + urllib.parse.urlencode(
|
||||
{
|
||||
"client_id": CLIENT_ID,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"response_type": "code",
|
||||
"scope": "openid",
|
||||
"state": "dev-token",
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
)
|
||||
login_page = opener.open(auth_url).read().decode()
|
||||
action = re.search(r'action="([^"]+)"', login_page)
|
||||
if not action:
|
||||
print("ไม่พบ login form — ตรวจ Keycloak ที่ https://localhost:8443", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
credentials = urllib.parse.urlencode(
|
||||
{"username": USERNAME, "password": PASSWORD, "credentialId": ""}
|
||||
).encode()
|
||||
try:
|
||||
opener.open(urllib.request.Request(action.group(1).replace("&", "&"), data=credentials))
|
||||
print("login ไม่ redirect — ตรวจ username/password", file=sys.stderr)
|
||||
return 1
|
||||
except urllib.error.HTTPError as error:
|
||||
location = error.headers.get("Location", "")
|
||||
code = urllib.parse.parse_qs(urllib.parse.urlparse(location).query).get("code", [None])[0]
|
||||
if not code:
|
||||
print("ไม่ได้รับ authorization code (redirect ผิดหรือ login พลาด)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
token_body = urllib.parse.urlencode(
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": CLIENT_ID,
|
||||
"code": code,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"code_verifier": verifier,
|
||||
}
|
||||
).encode()
|
||||
token_response = json.loads(
|
||||
opener.open(urllib.request.Request(f"{REALM}/protocol/openid-connect/token", data=token_body)).read()
|
||||
)
|
||||
token = token_response["access_token"]
|
||||
|
||||
if args.clip:
|
||||
import subprocess
|
||||
|
||||
subprocess.run("pbcopy", input=token.encode(), check=True)
|
||||
print(f"คัดลอก access token ({len(token)} chars) เข้า clipboard แล้ว")
|
||||
else:
|
||||
print(token)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
# Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend)
|
||||
|
||||
> ฉบับแก้ไข v3 (2026-08-19): เพิ่มข้อจำกัดแพลตฟอร์มที่พบจากการทดสอบจริงบน Word for Mac (dialog บังคับ HTTPS, mixed content, รูปแบบ slice.data หลายแบบ, webview ทิ้ง blob download) + กลไกดาวน์โหลด version ผ่าน presigned link
|
||||
> ฉบับแก้ไข v2 (2026-08-18): เพิ่ม Web Portal (Nuxt.js) + Template System (FR-9 ถึง FR-11) + field sync — implementation plan อยู่ที่ `docs/plans/officejs-implementation-plan.md`
|
||||
|
||||
## 1. ภาพรวม
|
||||
|
|
@ -31,6 +32,13 @@
|
|||
- **Custom Document Properties เสถียรกว่าและ cross-platform ดีกว่า** → ใช้เป็น primary flag สำหรับตรวจจับว่าไฟล์ผ่าน add-in นี้หรือไม่
|
||||
- Manifest ต้อง support ทั้ง requirement set ที่ `document.customXmlParts`, `document.properties.customProperties`, และ `document.save()` (WordApi 1.5+) ใช้งานได้
|
||||
|
||||
ข้อจำกัดเพิ่มเติมจากการทดสอบจริงบน Word for Mac (v3):
|
||||
|
||||
- **Office Dialog API ปฏิเสธ URL ที่เป็น http:// โดยสมบูรณ์** (error "The HTTP protocol is not supported. Use HTTPS instead") → หน้า login ของ IdP ต้องเสิร์ฟ HTTPS เสมอแม้ตอน dev
|
||||
- **Webview ของ host (โดยเฉพาะ WKWebView ใน Word for Mac) บล็อก fetch จากหน้า https ไป http แบบเงียบๆ** (mixed content — error "Load failed", request ไม่เคยถึง server, CORS ไม่เกี่ยว) → ทุก service ที่ task pane (หน้า https) จะเรียกต้องเป็น HTTPS ทั้งหมด
|
||||
- **ข้อมูล slice จาก `getSliceAsync` มีได้หลายรูปแบบตาม host/แพลตฟอร์ม** — ArrayBuffer, typed-array view, base64 string, หรือ `number[]` ธรรมดา → ต้อง normalize ครบทุกกรณีก่อนประกอบเป็นไฟล์
|
||||
- **Webview ของ host ทิ้ง blob download (`a[download]` + object URL) แบบเงียบๆ** — ไฟล์ไม่ถูกเขียนที่ไหนเลย → การดาวน์โหลดไฟล์จาก add-in ต้องขอ presigned URL อายุสั้นจาก backend แล้วเปิดในเบราว์เซอร์ของเครื่อง (`Office.context.ui.openBrowserWindow`) แทน
|
||||
|
||||
---
|
||||
|
||||
## 3. เทคโนโลยี
|
||||
|
|
@ -166,9 +174,11 @@ GET /api/documents/{documentId}/versions
|
|||
|
||||
### FR-7: ดาวน์โหลด version ที่ระบุ
|
||||
```
|
||||
GET /api/documents/{documentId}/versions/{versionId}/download
|
||||
GET /api/documents/{documentId}/versions/{versionId}/download # proxy stream (curl/Swagger)
|
||||
GET /api/documents/{documentId}/versions/{versionId}/download-link # presigned URL อายุสั้น (add-in ใช้อันนี้)
|
||||
```
|
||||
- Stream ไฟล์จาก MinIO กลับไป พร้อม content-type ที่ถูกต้อง (ใช้ presigned URL หรือ proxy stream ผ่าน backend ก็ได้ — แนะนำ proxy stream เพื่อคุม auth ที่ endpoint เดียว ไม่ต้อง expose MinIO ตรงๆ ให้ client)
|
||||
- `download` — stream ไฟล์จาก MinIO กลับไป พร้อม content-type ที่ถูกต้อง
|
||||
- `download-link` — ออก **presigned URL อายุสั้น** (config ได้, default ~2 นาที, clamp 30 วิ–10 นาที) ให้ client เปิดในเบราว์เซอร์ของเครื่อง — เป็น**กลไกหลักของ add-in** เพราะ webview ของ host ทิ้ง blob download แบบเงียบๆ (v3) · เข้าถึง MinIO โดยตรงโดยไม่ต้องแนบ token แต่หมดอายุทันทีเมื่อเกินเวลา
|
||||
|
||||
### FR-8: Auth (Keycloak / OpenID Connect)
|
||||
|
||||
|
|
@ -302,7 +312,7 @@ Custom Document Properties:
|
|||
|---|---|
|
||||
| `SavedByAddIn` | GUID คงที่ของ add-in — ค่าที่ปักหมุด: `9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D` (backend: `Services/AddInMetadata.AddInSignatureGuid`, add-in: `ADDIN_SIGNATURE_GUID` ต้องเป็นค่านี้เป๊ะ) |
|
||||
| `DocumentTrackingId` | GUID เฉพาะของเอกสาร (ผูกกับ `documentId` ฝั่ง backend) |
|
||||
| `LastVersionId` | GUID ของ version ล่าสุด |
|
||||
| `LastVersionId` | GUID ของ version ล่าสุด — คือ `clientVersionId` ที่ client generate (ต้อง stamp ลงไฟล์*ก่อน* save/upload จึงจะติดไปกับ snapshot; versionId จริงของ backend ได้มาหลังไฟล์ถูก capture ไปแล้ว — backend คือ source of truth ของ id จริง) |
|
||||
| `LastVersionTimestamp` | ISO-8601 UTC timestamp |
|
||||
|
||||
Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact history เสริม (ไม่ใช่ source of truth, backend คือ source of truth):
|
||||
|
|
@ -333,6 +343,7 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
|
|||
- OpenXML replace ต้องแก้ที่ **run level ใน SDT** เพื่อรักษา formatting ของ template — ห้ามทับทั้ง paragraph
|
||||
- Web Portal ทุกหน้ารองรับ **light/dark theme** (toggle + persist + default ตาม `prefers-color-scheme`)
|
||||
- **Dev server ports ห้ามชนกัน:** add-in = `https://localhost:3000`, Web Portal = `http://localhost:5173` (ตั้งใน `nuxt.config.ts`)
|
||||
- **Dev ต้องเสิร์ฟ HTTPS ด้วย cert ของ `office-addin-dev-certs` ทุก service ที่ task pane เรียก** (v3): Keycloak = `https://localhost:8443` (dialog บังคับ HTTPS), backend = `https://localhost:7232` เพิ่มคู่กับ `http://localhost:5075` (กัน mixed content) — ต้องรัน `npx office-addin-dev-certs install` **ก่อน** `docker compose up` เพราะ Keycloak mount cert จาก `~/.office-addin-dev-certs`
|
||||
- Backend CORS ต้องอนุญาตทั้ง origin ของ add-in (`https://localhost:3000`) และ Web Portal (`http://localhost:5173`)
|
||||
|
||||
---
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue