Compare commits
4 commits
7a146d48be
...
2b70a50c62
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b70a50c62 | ||
|
|
dc851fbb73 | ||
|
|
33670d0b18 | ||
|
|
c401f4deb7 |
39 changed files with 18703 additions and 17 deletions
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -31,5 +31,14 @@ next-env.d.ts
|
||||||
# N4: ไฟล์ที่ผู้ใช้อัปโหลดผ่านหน้ารายการ (รันไทม์ — ไม่เข้า repo)
|
# N4: ไฟล์ที่ผู้ใช้อัปโหลดผ่านหน้ารายการ (รันไทม์ — ไม่เข้า repo)
|
||||||
public/note/uploads/
|
public/note/uploads/
|
||||||
|
|
||||||
|
# wordaddin: Office.js runtime สร้างใหม่ได้เสมอจาก postinstall scripts/fetch-office-js.mjs
|
||||||
|
# (ร้อยกว่าไฟล์หลาย MB — ไม่ commit เข้า repo เหมือน swagger-ui ที่มีแค่ 2 ไฟล์)
|
||||||
|
public/wordaddin/office-js/
|
||||||
|
|
||||||
|
# wordaddin: ใบรับรอง https ของ dev (สร้างเองเฉพาะเครื่อง — ดู docs/WORDADDIN-DEV.md)
|
||||||
|
/certs/
|
||||||
|
|
||||||
# compose ทดสอบบนเครื่อง dev (build จาก repo + DB แยก รันที่ 6704) — ไม่ใช่ของ production
|
# compose ทดสอบบนเครื่อง dev (build จาก repo + DB แยก รันที่ 6704) — ไม่ใช่ของ production
|
||||||
docker-compose.test.yml
|
docker-compose.test.yml
|
||||||
|
|
||||||
|
certificates
|
||||||
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -12,6 +12,7 @@
|
||||||
- Next.js 14 (App Router) + TypeScript **strict** — serve ใต้ basePath `/docedit` + route ทั้งหมดของแอปใต้ `app/note/**` (URL ปลายทางยังเป็น `/docedit/note/…` เหมือนเดิม — basePath `/docedit` เป็นพื้นที่กลางรองรับ sibling ในอนาคต เช่น wordaddin, `next.config.mjs`)
|
- Next.js 14 (App Router) + TypeScript **strict** — serve ใต้ basePath `/docedit` + route ทั้งหมดของแอปใต้ `app/note/**` (URL ปลายทางยังเป็น `/docedit/note/…` เหมือนเดิม — basePath `/docedit` เป็นพื้นที่กลางรองรับ sibling ในอนาคต เช่น wordaddin, `next.config.mjs`)
|
||||||
- pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/note/pdf.worker.min.mjs` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกจาก node_modules + **patch fake-space ไทย** — รายละเอียด invariant #6)
|
- pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/note/pdf.worker.min.mjs` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกจาก node_modules + **patch fake-space ไทย** — รายละเอียด invariant #6)
|
||||||
- Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/note/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`)
|
- Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/note/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`)
|
||||||
|
- **wordaddin** — taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route `/docedit/wordaddin` ของแอปนี้ (UI = Genesis, ชั้น services Office.js พอร์ตดิบจาก `D:\word-addin-poc\addin`) — ปัจจุบัน auth + API เป็น **mock** รอของจริง (spec UI: `docs/addin-ui-flow.md`, คู่มือ dev/sideload/สลับกลับ: `docs/WORDADDIN-DEV.md`); Office.js **self-host** ที่ `public/wordaddin/office-js/` ผ่าน postinstall `scripts/fetch-office-js.mjs` — gitignore (ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft ตอนติดตั้ง ตัวระบบ deploy ใช้ intranet ล้วน; แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` ใช้ไม่ได้)
|
||||||
- Tailwind CSS + design system **Genesis** (`docs/DESIGN.md`, 2026-09-23) — palette tokens ใน `tailwind.config.ts` (`primary` เดิม + `primary-hover`/`canvas`/`line`/`ink`/`ink-soft`/`ink-faint`/`success`/`warning`/`error` + ระดับข้อความ `success-ink`/`warning-ink`/`error-ink` ≥5:1 สำหรับข้อความ/ไอคอนสถานะ — token 500 ใช้กับ border/bg tint เท่านั้น) ห้ามใช้ raw palette shades ของ Tailwind (`slate-*`/`amber-*`/`emerald-*`/`red-*` ฯลฯ) อีก; ฟอนต์ display = **General Sans** (self-host จาก Fontshare ผ่าน `next/font/local` — `app/fonts/`, variable weight 200–700, ใช้ intranet ได้), body = **DM Sans** (`next/font/google`), ไทย fallback **Sarabun** ตามลำดับใน stack (Latin ก่อนไทย — `fontFamily`/globals.css); รัศมี: ปุ่ม/input 6px (รวมปุ่ม action เล็กใน popup/composer/dialog — คู่ ยกเลิก/บันทึก ต้องได้รัศมีเดียวกัน), menu/list row ใน dropdown/panel 4px, panel/dropdown 8px, การ์ด 12px; เงาเฉพาะ hover (`shadow-card-hover`/`shadow-btn-glow` — static ไม่มีเงา ยกเว้นชั้น floating: dropdown/popover `shadow-lg` และ pin ลอยบนเอกสาร ดู §App-specific exceptions ของ DESIGN.md); focus indicator = outline น้ำเงินทึบ 2px + offset ผ่าน class `.focus-ring`/`.genesis-input` ใน globals.css (+ สำเนา `FOCUS_RING_CLS` ใน PDFViewer/AnnotationSidebar/AnnotationSelectionLayer/ToolControls — แก้ต้องแก้พร้อมกัน; halo alpha จางเกิน WCAG 1.4.11); น้ำเงินเฉพาะของ interactive (แบรนด์มาร์ก tile JN/แถบหัวหน้าสรุปยกเว้น)
|
- Tailwind CSS + design system **Genesis** (`docs/DESIGN.md`, 2026-09-23) — palette tokens ใน `tailwind.config.ts` (`primary` เดิม + `primary-hover`/`canvas`/`line`/`ink`/`ink-soft`/`ink-faint`/`success`/`warning`/`error` + ระดับข้อความ `success-ink`/`warning-ink`/`error-ink` ≥5:1 สำหรับข้อความ/ไอคอนสถานะ — token 500 ใช้กับ border/bg tint เท่านั้น) ห้ามใช้ raw palette shades ของ Tailwind (`slate-*`/`amber-*`/`emerald-*`/`red-*` ฯลฯ) อีก; ฟอนต์ display = **General Sans** (self-host จาก Fontshare ผ่าน `next/font/local` — `app/fonts/`, variable weight 200–700, ใช้ intranet ได้), body = **DM Sans** (`next/font/google`), ไทย fallback **Sarabun** ตามลำดับใน stack (Latin ก่อนไทย — `fontFamily`/globals.css); รัศมี: ปุ่ม/input 6px (รวมปุ่ม action เล็กใน popup/composer/dialog — คู่ ยกเลิก/บันทึก ต้องได้รัศมีเดียวกัน), menu/list row ใน dropdown/panel 4px, panel/dropdown 8px, การ์ด 12px; เงาเฉพาะ hover (`shadow-card-hover`/`shadow-btn-glow` — static ไม่มีเงา ยกเว้นชั้น floating: dropdown/popover `shadow-lg` และ pin ลอยบนเอกสาร ดู §App-specific exceptions ของ DESIGN.md); focus indicator = outline น้ำเงินทึบ 2px + offset ผ่าน class `.focus-ring`/`.genesis-input` ใน globals.css (+ สำเนา `FOCUS_RING_CLS` ใน PDFViewer/AnnotationSidebar/AnnotationSelectionLayer/ToolControls — แก้ต้องแก้พร้อมกัน; halo alpha จางเกิน WCAG 1.4.11); น้ำเงินเฉพาะของ interactive (แบรนด์มาร์ก tile JN/แถบหัวหน้าสรุปยกเว้น)
|
||||||
- mock login e-Court — หน้า `/note/login` (dropdown `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` ตาม format จริงของระบบศาล → identity (userId = `u_userid`, userName = `u_userlogin` — **ต่างกันต่อคน** เพื่อสิทธิ์เจ้าของ) อ่านจาก cookie ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope)
|
- mock login e-Court — หน้า `/note/login` (dropdown `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` ตาม format จริงของระบบศาล → identity (userId = `u_userid`, userName = `u_userlogin` — **ต่างกันต่อคน** เพื่อสิทธิ์เจ้าของ) อ่านจาก cookie ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope)
|
||||||
|
|
||||||
|
|
@ -28,16 +29,17 @@
|
||||||
9. **Optimistic update ใช้ `useOptimistic` (React 18) ภายใน `startTransition`** — React rollback อัตโนมัติเมื่อ action ล้มเหลว + dedupe ด้วย id ใน `hooks/useAnnotationRealtime.ts` (duplicate ชั่วขณะกับ realtime event หายเอง)
|
9. **Optimistic update ใช้ `useOptimistic` (React 18) ภายใน `startTransition`** — React rollback อัตโนมัติเมื่อ action ล้มเหลว + dedupe ด้วย id ใน `hooks/useAnnotationRealtime.ts` (duplicate ชั่วขณะกับ realtime event หายเอง)
|
||||||
10. **Page Virtualization:** render เฉพาะหน้าใน viewport ± 1 หน้าด้วย IntersectionObserver — หน้าพ้นจอเก็บ placeholder ขนาดจริง (scroll ไม่ jump, bookmark ยังคำนวณตำแหน่งได้) และ unmount canvas/TextLayer/InkLayer คืน memory — ห้าม render ทุกหน้าพร้อมกัน
|
10. **Page Virtualization:** render เฉพาะหน้าใน viewport ± 1 หน้าด้วย IntersectionObserver — หน้าพ้นจอเก็บ placeholder ขนาดจริง (scroll ไม่ jump, bookmark ยังคำนวณตำแหน่งได้) และ unmount canvas/TextLayer/InkLayer คืน memory — ห้าม render ทุกหน้าพร้อมกัน
|
||||||
11. **DB connection:** `DATABASE_URL` ชี้ที่ plain PostgreSQL container `judes-note-db` (`postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note`) + `prepare: false` ใน postgres.js เสมอ — pub/sub ของ SSE เป็น in-memory (ใช้ได้เมื่อรัน process เดียว; ถ้ารันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน)
|
11. **DB connection:** `DATABASE_URL` ชี้ที่ plain PostgreSQL container `judes-note-db` (`postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note`) + `prepare: false` ใน postgres.js เสมอ — pub/sub ของ SSE เป็น in-memory (ใช้ได้เมื่อรัน process เดียว; ถ้ารันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน)
|
||||||
12. **URL ฝั่ง browser ที่สร้างเองต้องผ่าน `withBasePath()`** (`utils/base-path.ts` — เติม basePath `/docedit` **+ app path `/note`** จาก env `NEXT_PUBLIC_BASE_PATH`/`NEXT_PUBLIC_APP_PATH` ประกาศที่ `next.config.mjs` ที่เดียว) ครอบคลุม: `fetch()`/`EventSource`/plain `<a href>`/`workerSrc`/`img.src`/`file.pdfUrl` ตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน `<Link>`/`next/navigation` (router.push/replace/redirect) Next เติม basePath `/docedit` ให้เองเท่านั้น **path ที่เขียนถึง route จึงต้องมี `/note` นำหน้าเสมอ** (เช่น `href="/note/login"`, `redirect("/note/…")`, `revalidatePath("/note/…")`) **ยกเว้น API — route API อยู่ใต้ `app/api/note/**` (URL `/docedit/api/note/…`): path ที่ขึ้นต้น `/api/` เขียนครบเป็น `/api/note/…` แล้ว `withBasePath` เติมแค่ basePath ไม่มี `/note` ซ้อน**; `middleware.ts` จับ path ที่ตัด basePath แล้ว (หน้า/ไฟล์ของแอปขึ้นต้น `/note/…` ส่วน API ขึ้นต้น `/api/note/…` — รายการยกเว้นใน matcher: ของแอปนำหน้า `note/` เช่น `note/uploads`, API เป็น `api/note`, ส่วน `fonts`/`favicon` อยู่ public/ รากไม่มี prefix — NextURL ใส่ basePath กลับตอน redirect เอง); `pdf_url` ใน DB เก็บ logical path ไม่รวม prefix เติมตอนใช้: ฝั่ง browser ผ่าน `withBasePath` / ฝั่ง disk ผ่าน `resolvePublicPdfPath` (`lib/server-pdf.ts` root ที่ `public/note/` — statics ของแอปอยู่ใต้ `public/note/**` ทั้งหมดยกเว้นฟอนต์ export)
|
12. **URL ฝั่ง browser ที่สร้างเองต้องผ่าน `withBasePath()`** (`utils/base-path.ts` — เติม basePath `/docedit` **+ app path `/note`** จาก env `NEXT_PUBLIC_BASE_PATH`/`NEXT_PUBLIC_APP_PATH` ประกาศที่ `next.config.mjs` ที่เดียว) ครอบคลุม: `fetch()`/`EventSource`/plain `<a href>`/`workerSrc`/`img.src`/`file.pdfUrl` ตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน `<Link>`/`next/navigation` (router.push/replace/redirect) Next เติม basePath `/docedit` ให้เองเท่านั้น **path ที่เขียนถึง route จึงต้องมี `/note` นำหน้าเสมอ** (เช่น `href="/note/login"`, `redirect("/note/…")`, `revalidatePath("/note/…")`) **ยกเว้น API — route API อยู่ใต้ `app/api/note/**` (URL `/docedit/api/note/…`): path ที่ขึ้นต้น `/api/` เขียนครบเป็น `/api/note/…` แล้ว `withBasePath` เติมแค่ basePath ไม่มี `/note` ซ้อน — เช่นเดียวกับ static/หน้าของ sibling wordaddin: path ที่ขึ้นต้น `/wordaddin/` (office.js self-host, manifest, icons) เติมแค่ basePath**; `middleware.ts` จับ path ที่ตัด basePath แล้ว (หน้า/ไฟล์ของแอปขึ้นต้น `/note/…` ส่วน API ขึ้นต้น `/api/note/…` — รายการยกเว้นใน matcher: ของแอปนำหน้า `note/` เช่น `note/uploads`, API เป็น `api/note`, `wordaddin` (taskpane ของ Word add-in — รันใน webview ของ Word ไม่มี cookie oacCase และมี auth ของตัวเองแยกจากแอป note), ส่วน `fonts`/`favicon` อยู่ public/ รากไม่มี prefix — NextURL ใส่ basePath กลับตอน redirect เอง); `pdf_url` ใน DB เก็บ logical path ไม่รวม prefix เติมตอนใช้: ฝั่ง browser ผ่าน `withBasePath` / ฝั่ง disk ผ่าน `resolvePublicPdfPath` (`lib/server-pdf.ts` root ที่ `public/note/` — statics ของแอปอยู่ใต้ `public/note/**` ทั้งหมดยกเว้นฟอนต์ export)
|
||||||
|
|
||||||
## โครงสร้างไฟล์ (ตามแผน)
|
## โครงสร้างไฟล์ (ตามแผน)
|
||||||
|
|
||||||
```
|
```
|
||||||
scripts/patch-pdf-worker.mjs ← postinstall: คัดลอก pdf.worker.min.mjs จาก node_modules ไป public/note/ + patch fake-space ไทย (invariant #6 — เจอ pattern ≠ 1 จุดหลังอัปเกรด pdfjs-dist = exit 1 ดัง ๆ)
|
scripts/patch-pdf-worker.mjs ← postinstall: คัดลอก pdf.worker.min.mjs จาก node_modules ไป public/note/ + patch fake-space ไทย (invariant #6 — เจอ pattern ≠ 1 จุดหลังอัปเกรด pdfjs-dist = exit 1 ดัง ๆ)
|
||||||
scripts/copy-swagger-ui.mjs ← postinstall ตัวที่สอง: คัดลอก swagger-ui-dist (bundle+css) ไป public/note/swagger-ui/ สำหรับหน้า Swagger UI ที่ GET /api/swagger (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 ดัง ๆ เหมือน patch-pdf-worker; Docker build รัน script นี้ด้วย ดู docker/web.Dockerfile)
|
scripts/copy-swagger-ui.mjs ← postinstall ตัวที่สอง: คัดลอก swagger-ui-dist (bundle+css) ไป public/note/swagger-ui/ สำหรับหน้า Swagger UI ที่ GET /api/swagger (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 ดัง ๆ เหมือน patch-pdf-worker; Docker build รัน script นี้ด้วย ดู docker/web.Dockerfile)
|
||||||
|
scripts/fetch-office-js.mjs ← postinstall ตัวที่สาม: ดาวน์โหลด Office.js runtime **ฉบับ CDN ล่าสุด** (appsforoffice.microsoft.com — ชุดไฟล์เฉพาะ Word: loader + host files ราย platform + legacy 15.x + o15apptofilemappingtable/microsoftajax + office_strings.js ต่อ locale en-us/th-th บังคับ) ไป public/wordaddin/office-js/ ให้ taskpane โหลด same-origin — npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ไม่มี `Document.close`/enum `CloseBehavior` ที่ runtime ใหม่+`@types/office-js` มี; ไฟล์หลักหาย/ตรวจไม่เจอ `"Document.close"`+`CloseBehavior` ใน word-win32-16.01.js หลังโหลด = exit 1 ดัง ๆ (ไฟล์ปลายทาง gitignore — ดาวน์โหลดใหม่ทุกครั้ง; ติดตั้งต้องออกเน็ตถึง CDN ของ Microsoft ส่วน runtime ตัวระบบ intranet ล้วน)
|
||||||
db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO
|
db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO
|
||||||
db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false)
|
db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false)
|
||||||
middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/swagger/* (หน้า Swagger UI), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon)
|
middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/swagger/* (หน้า Swagger UI), wordaddin/* (taskpane ของ Word add-in — auth mock ของตัวเอง), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ))
|
||||||
lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe)
|
lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe)
|
||||||
lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้)
|
lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้)
|
||||||
lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court)
|
lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court)
|
||||||
|
|
@ -46,6 +48,10 @@ lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allow
|
||||||
lib/external-api-auth.ts ← N10 auth ของ /api/note/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB)
|
lib/external-api-auth.ts ← N10 auth ของ /api/note/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB)
|
||||||
lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=<id>` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม)
|
lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=<id>` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม)
|
||||||
lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย
|
lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย
|
||||||
|
wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md)
|
||||||
|
wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (mock auth)
|
||||||
|
wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ)
|
||||||
|
wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ · **authService = MOCK** (login ผ่านทันที คง surface เดิมครบไว้สลับของจริง — ต้นฉบับ Keycloak PKCE อยู่ที่ D:\word-addin-poc\addin + **`signInFromDocument` e-Court pass-through 2026-10-16: ไฟล์ที่ stamp custom properties `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = `detect()` ของ TaskpaneApp ผ่านเข้าเลยไม่ต้องกด login — username จาก owner → uid ใน JWT → mockUsername, token ไฟล์เก็บ memory ให้ getAccessToken ใช้ต่อ**) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank)
|
||||||
utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine
|
utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine
|
||||||
utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %)
|
utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %)
|
||||||
utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม)
|
utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม)
|
||||||
|
|
@ -74,6 +80,9 @@ app/note/groups/[typeId]/page.tsx ← N9 หน้าเอกสารใน
|
||||||
app/note/login/page.tsx ← หน้า login (มี session แล้ว redirect /note)
|
app/note/login/page.tsx ← หน้า login (มี session แล้ว redirect /note)
|
||||||
app/note/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer
|
app/note/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer
|
||||||
app/note/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark ของไฟล์เดียว (server component — query ทั้งหมดฝั่ง server แล้วส่ง `ExplorerItem[]` เรียง หน้า → หน้าเดียวกัน bookmark ก่อน annotation (bookmark ต่อหน้าเรียงตาม yOffset N5 เหมือนเดิม, annotation ตาม createdAt) ให้ `SummaryExplorer` กรองใน memory; design "แฟ้มสำนวน" 2026-09-17 แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio + รายการตามหน้าจุดไข่ปลา → **redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer 2 คอลัมน์แบบหน้ากลุ่ม N9 โดยมิติแรกของตัวกรองเป็น "หน้า" แทน "ไฟล์"** — แถบหัวน้ำเงิน + action bar (กลับหน้าอ่าน — ปุ่มส่งออก CSV F16/ส่งออก Bookmark JSON N7 ถอด 2026-09-25 แทนด้วย N10 API) + empty state คงเดิม, ไฟล์ว่าง = ไม่ mount explorer เลย; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + แผงตัวกรอง/แถบ action ซ่อนด้วย print:hidden — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ <main> (กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`)
|
app/note/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark ของไฟล์เดียว (server component — query ทั้งหมดฝั่ง server แล้วส่ง `ExplorerItem[]` เรียง หน้า → หน้าเดียวกัน bookmark ก่อน annotation (bookmark ต่อหน้าเรียงตาม yOffset N5 เหมือนเดิม, annotation ตาม createdAt) ให้ `SummaryExplorer` กรองใน memory; design "แฟ้มสำนวน" 2026-09-17 แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio + รายการตามหน้าจุดไข่ปลา → **redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer 2 คอลัมน์แบบหน้ากลุ่ม N9 โดยมิติแรกของตัวกรองเป็น "หน้า" แทน "ไฟล์"** — แถบหัวน้ำเงิน + action bar (กลับหน้าอ่าน — ปุ่มส่งออก CSV F16/ส่งออก Bookmark JSON N7 ถอด 2026-09-25 แทนด้วย N10 API) + empty state คงเดิม, ไฟล์ว่าง = ไม่ mount explorer เลย; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + แผงตัวกรอง/แถบ action ซ่อนด้วย print:hidden — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ <main> (กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`)
|
||||||
|
app/wordaddin/layout.tsx + page.tsx ← taskpane ของ Word add-in (URL `/docedit/wordaddin` — nested layout แรกของ repo override title แท็บ browser; page เป็น server component บาง ๆ render WordAddinGate ทั้งหมดฝั่ง client)
|
||||||
|
components/wordaddin/WordAddinGate.tsx ← client gate: ฉีด script office.js self-host (ผ่าน withBasePath "/wordaddin/office-js/office.js") → Office.onReady → mount TaskpaneApp · `?addindemo=untracked|tracking` (dev-only) ข้าม office.js + mock sign in + ตั้ง phase ตรง เพื่อดู UI ใน browser ธรรมดา — flow ที่แตะ Word จริงจะจบที่ error ตาม expected (FR-17: ใน browser ปกติ banner auto-save error โผล่หลัง ~30 วิ — ถูกต้อง)
|
||||||
|
components/wordaddin/TaskpaneApp.tsx ← state machine checking/signed-out/untracked/tracking + handler ทุกตัว พอร์ตจาก App.tsx ต้นฉบับ (state/handlers คงเดิม เปลี่ยนแค่ JSX → Genesis) · SaveControls/MessageBar/Spinner/ConfirmVersionDialog(portal แบบ AnnotationSidebar)/VersionHistoryPanel
|
||||||
components/summary/SummaryExplorer.tsx ← client root ของหน้าสรุป (redesign 2026-09-24) — state ตัวกรอง `FacetFilterState` จากแผงกลาง explorer + กรองใน memory + facet count/static users derive เอง + มิติ "หน้า" เป็นตัวเลือกแบบ single-select เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (`optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหน้าเยอะดันกลุ่มอื่นออกจอ) + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `<details>`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `<div>` ไม่ใช่ `<section>` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน)
|
components/summary/SummaryExplorer.tsx ← client root ของหน้าสรุป (redesign 2026-09-24) — state ตัวกรอง `FacetFilterState` จากแผงกลาง explorer + กรองใน memory + facet count/static users derive เอง + มิติ "หน้า" เป็นตัวเลือกแบบ single-select เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (`optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหน้าเยอะดันกลุ่มอื่นออกจอ) + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `<details>`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `<div>` ไม่ใช่ `<section>` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน)
|
||||||
components/summary/SummaryList.tsx ← คอลัมน์ content ของหน้าสรุป — การ์ดรายหน้า "1 หน้า 1 การ์ด" หัวการ์ด sticky ระดับ viewport (เลขหน้า + บรรทัดสรุปหน้า `pageSummaryLine` **คำนวณจากรายการที่เห็นจริงหลังกรอง** + ปุ่ม "เปิดหน้านี้"; กลับเป็น position static ตอนพิมพ์ผ่านกฎ `.page-head` ใน globals.css) + แถว passive จุดคลิกเดียวคือปุ่ม `<Link>` "View" เหมือน GroupList (deep link `?ann=`/`?page=` — bookmark ไปหน้า, annotation ไปรายการ; meta line ไม่มี segment ไฟล์เพราะไฟล์เดียวทั้งหน้า) + header "พบ N รายการ" (aria-live) + ปุ่ม "ล้างตัวกรอง" (เฉพาะมีตัวกรอง active) + empty state กรองจนไม่เหลือ + **ตอนพิมพ์แสดงฉบับเต็มเสมอ** — ตัวกรองที่ตั้งค้างต้องไม่ตัดรายการออกเงียบ ๆ บนกระดาษ: ตอน activeCount > 0 render ชุดบนจอ (print:hidden) + ชุดเต็ม (hidden print:block, ไม่มี id/aria-labelledby ซ้ำ) แยกกัน; anchor `#page-N` ของ section คงจาก design เดิมให้ลิงก์เก่ายังใช้ได้
|
components/summary/SummaryList.tsx ← คอลัมน์ content ของหน้าสรุป — การ์ดรายหน้า "1 หน้า 1 การ์ด" หัวการ์ด sticky ระดับ viewport (เลขหน้า + บรรทัดสรุปหน้า `pageSummaryLine` **คำนวณจากรายการที่เห็นจริงหลังกรอง** + ปุ่ม "เปิดหน้านี้"; กลับเป็น position static ตอนพิมพ์ผ่านกฎ `.page-head` ใน globals.css) + แถว passive จุดคลิกเดียวคือปุ่ม `<Link>` "View" เหมือน GroupList (deep link `?ann=`/`?page=` — bookmark ไปหน้า, annotation ไปรายการ; meta line ไม่มี segment ไฟล์เพราะไฟล์เดียวทั้งหน้า) + header "พบ N รายการ" (aria-live) + ปุ่ม "ล้างตัวกรอง" (เฉพาะมีตัวกรอง active) + empty state กรองจนไม่เหลือ + **ตอนพิมพ์แสดงฉบับเต็มเสมอ** — ตัวกรองที่ตั้งค้างต้องไม่ตัดรายการออกเงียบ ๆ บนกระดาษ: ตอน activeCount > 0 render ชุดบนจอ (print:hidden) + ชุดเต็ม (hidden print:block, ไม่มี id/aria-labelledby ซ้ำ) แยกกัน; anchor `#page-N` ของ section คงจาก design เดิมให้ลิงก์เก่ายังใช้ได้
|
||||||
components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า)
|
components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า)
|
||||||
|
|
|
||||||
BIN
app/api/template/test/Test.docx
Normal file
BIN
app/api/template/test/Test.docx
Normal file
Binary file not shown.
22
app/page.tsx
22
app/page.tsx
|
|
@ -1,23 +1,33 @@
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
// หน้า root (/docedit) — แทน redirects() เดิมใน next.config.mjs (ถอดออกลด hop
|
// หน้า root (/docedit) — แทน redirects() เดิมใน next.config.mjs (ถอดออกลด hop
|
||||||
// ที่ Next สั่งเองตอน deploy หลัง IIS/proxy): พาเข้าหน้ารายการของ /note เสมอ
|
// ที่ Next สั่งเองตอน deploy หลัง IIS/proxy)
|
||||||
// — redirect() ไม่แนบ searchParams ให้อัตโนมัติเหมือน redirects() จึงต้องส่งต่อ
|
// ตามคำขอผู้ใช้ 2026-10-14: เปิด /docedit เปล่า ๆ → เข้า taskpane ของ wordaddin
|
||||||
// เอง ไม่งั้นสัญญาลิงก์ e-Court /docedit/?fileUrl=&fileId=&typeId= (N8) พัง
|
// เลย (/wordaddin — middleware ไม่บังคับ login ที่ root) ยกเว้น**ลิงก์ e-Court**
|
||||||
|
// /docedit/?fileUrl=&fileId=&typeId= (N8) ที่ยังส่งต่อให้ /note เหมือนเดิม —
|
||||||
|
// redirect() ไม่แนบ searchParams ให้อัตโนมัติเหมือน redirects() จึงต้องส่งต่อเอง
|
||||||
// (redirect ของ next/navigation เติม basePath ให้เอง — invariant #12)
|
// (redirect ของ next/navigation เติม basePath ให้เอง — invariant #12)
|
||||||
|
const ECOURT_PARAM_KEYS = ["fileUrl", "fileId", "typeId"] as const;
|
||||||
|
|
||||||
export default function RootPage({
|
export default function RootPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Record<string, string | string[] | undefined>;
|
searchParams: Record<string, string | string[] | undefined>;
|
||||||
}) {
|
}) {
|
||||||
|
const entries = Object.entries(searchParams);
|
||||||
|
const isEcourtLink = entries.some(([key]) =>
|
||||||
|
(ECOURT_PARAM_KEYS as readonly string[]).includes(key),
|
||||||
|
);
|
||||||
|
if (!isEcourtLink) {
|
||||||
|
redirect("/wordaddin");
|
||||||
|
}
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(searchParams)) {
|
for (const [key, value] of entries) {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
qs.set(key, value);
|
qs.set(key, value);
|
||||||
} else if (Array.isArray(value)) {
|
} else if (Array.isArray(value)) {
|
||||||
for (const v of value) qs.append(key, v);
|
for (const v of value) qs.append(key, v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const query = qs.toString();
|
redirect(`/note?${qs.toString()}`);
|
||||||
redirect(`/note${query ? `?${query}` : ""}`);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
app/wordaddin/layout.tsx
Normal file
10
app/wordaddin/layout.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
/** nested layout แรกของ repo — override ชื่อแท็บ browser ของ taskpane (ส่วนอื่นสืบทอดจาก root layout) */
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "e-Court Documents Versioning",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WordAddinLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
7
app/wordaddin/page.tsx
Normal file
7
app/wordaddin/page.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import WordAddinGate from "@/components/wordaddin/WordAddinGate";
|
||||||
|
|
||||||
|
/** Taskpane ของ Word add-in (URL /docedit/wordaddin) — server component บาง ๆ ทั้งหมดอยู่ฝั่ง client
|
||||||
|
* auth/API เป็น mock ของ wordaddin เอง (ไม่ใช้ cookie oacCase — middleware ยกเว้น path นี้) */
|
||||||
|
export default function WordAddinPage() {
|
||||||
|
return <WordAddinGate />;
|
||||||
|
}
|
||||||
166
components/wordaddin/ConfirmVersionDialog.tsx
Normal file
166
components/wordaddin/ConfirmVersionDialog.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
import { Spinner } from "./Spinner";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FR-2 ขั้นตอนที่ 1 — dialog ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?" พร้อมชื่อ commit
|
||||||
|
* message ทางเลือก (FR-13) — ใช้ร่วมกับทั้ง Save & Version และ Start Versioning
|
||||||
|
* FR-18 — มี onConfirmClose = มีปุ่มที่สอง [บันทึกเวอร์ชันและปิดเอกสาร] เป็น primary ตัวเดียว
|
||||||
|
* (ปุ่มยืนยันหลักกลายเป็น secondary) ส่งเฉพาะ platform ที่ปิดเอกสารได้
|
||||||
|
* พอร์ตจาก ConfirmVersionDialog.tsx ต้นฉบับ — แทน Dialog ของ Fluent ด้วย portal + Genesis
|
||||||
|
* (pattern overlay เดียวกับ AnnotationSidebar)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_TITLE = "สร้าง version ใหม่?";
|
||||||
|
const DEFAULT_DESCRIPTION = "ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้";
|
||||||
|
const DEFAULT_CONFIRM_LABEL = "สร้าง version";
|
||||||
|
|
||||||
|
const BTN_SECONDARY =
|
||||||
|
"focus-ring rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50";
|
||||||
|
const BTN_PRIMARY =
|
||||||
|
"focus-ring rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50";
|
||||||
|
|
||||||
|
export function ConfirmVersionDialog({
|
||||||
|
open,
|
||||||
|
busy,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel,
|
||||||
|
onConfirm,
|
||||||
|
onConfirmClose,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
/** กำลังอยู่ระหว่าง flow หลังกดยืนยันแล้ว — ปุ่มทั้งหมด disable + แสดง spinner + ปิดไม่ได้ */
|
||||||
|
busy: boolean;
|
||||||
|
/** ปรับข้อความตอน reuse กับ flow อื่น (FR-13 — Start Versioning ใช้ dialog ตัวเดียวกัน) */
|
||||||
|
title?: string;
|
||||||
|
description?: ReactNode;
|
||||||
|
confirmLabel?: string;
|
||||||
|
/** ได้รับ commit message ที่ผู้ใช้พิมพ์ (FR-13) — ว่างได้ */
|
||||||
|
onConfirm: (message: string) => void;
|
||||||
|
/** FR-18 — submit ที่สอง "บันทึกเวอร์ชันและปิดเอกสาร" */
|
||||||
|
onConfirmClose?: (message: string) => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}) {
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// component ถูก mount ค้างไว้ตลอด (TaskpaneApp render ไว้นอก phase branch) — ต้องเคลียร์ค่าตอนปิด
|
||||||
|
// ไม่ใช่ตอน unmount ไม่งั้นข้อความรอบก่อนหลุดมารอบถัดไป
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setMessage("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// ESC = ยกเลิก (เฉพาะไม่กำลัง busy — ตอน busy ห้ามปิดได้ทุกทางตาม spec)
|
||||||
|
// Tab กักไว้ใน dialog — สิ่งที่ Fluent Dialog เคยทำให้อัตโนมัติ (WCAG 2.1.2/2.4.3)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape" && !busy) onDismiss();
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||||
|
"button:not([disabled]), textarea:not([disabled]), [href], input, select, [tabindex]:not([tabindex='-1'])",
|
||||||
|
);
|
||||||
|
const first = focusables?.item(0);
|
||||||
|
const last = focusables?.item((focusables?.length ?? 0) - 1);
|
||||||
|
if (!first || !last) return;
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, busy, onDismiss]);
|
||||||
|
|
||||||
|
// โฟกัสเข้า dialog ตอนเปิด + คืน focus ให้ trigger เดิมตอนปิด (อีกส่วนที่ Fluent เคยทำให้)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const previous = document.activeElement as HTMLElement | null;
|
||||||
|
dialogRef.current?.focus();
|
||||||
|
return () => {
|
||||||
|
previous?.focus();
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4"
|
||||||
|
onClick={() => !busy && onDismiss()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title ?? DEFAULT_TITLE}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="w-full max-w-sm rounded-xl border border-line bg-white p-4 shadow-lg focus:outline-none"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 className="font-display text-base font-semibold text-ink">{title ?? DEFAULT_TITLE}</h2>
|
||||||
|
<p className="mt-1 text-sm text-ink-soft">{description ?? DEFAULT_DESCRIPTION}</p>
|
||||||
|
<label className="mt-3 block text-sm font-medium text-ink" htmlFor="wordaddin-version-message">
|
||||||
|
คำอธิบาย version (ไม่บังคับ)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="wordaddin-version-message"
|
||||||
|
className="genesis-input mt-1 block w-full resize-y"
|
||||||
|
value={message}
|
||||||
|
onChange={(event) => setMessage(event.target.value)}
|
||||||
|
placeholder="เช่น แก้ไขข้อ 3 สัญญาจ้าง"
|
||||||
|
maxLength={2048}
|
||||||
|
rows={3}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label="คำอธิบาย version"
|
||||||
|
/>
|
||||||
|
{busy ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Spinner label="กำลังดำเนินการ…" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{/* จอแคบ: 3 ปุ่มในบรรทัดเดียว wrap ไม่มีแบบแผน (ปุ่ม primary ตกบรรทัดเดี่ยวเป็นเศษ)
|
||||||
|
→ จัดเป็น 2 แถวโดยตั้งใจ — คู่ ยกเลิก/ยืนยัน บน + ปุ่ม primary เต็มความกว้างล่าง */}
|
||||||
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button type="button" className={BTN_SECONDARY} disabled={busy} onClick={onDismiss}>
|
||||||
|
ยกเลิก
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={onConfirmClose ? BTN_SECONDARY : BTN_PRIMARY}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onConfirm(message)}
|
||||||
|
>
|
||||||
|
{confirmLabel ?? DEFAULT_CONFIRM_LABEL}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{onConfirmClose ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN_PRIMARY} w-full`}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onConfirmClose(message)}
|
||||||
|
>
|
||||||
|
บันทึกเวอร์ชันและปิดเอกสาร
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConfirmVersionDialog;
|
||||||
45
components/wordaddin/MessageBar.tsx
Normal file
45
components/wordaddin/MessageBar.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { CircleAlert, CircleCheck, TriangleAlert } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genesis MessageBar ของ wordaddin — แทน MessageBar ของ Fluent UI v9 ต้นฉบับ
|
||||||
|
* intent error = ข้อความ/ไอคอนใช้เฉด -ink (contrast ≥5:1 ตาม DESIGN.md), พื้น tint จาง
|
||||||
|
* มี icon นำตามระดับ — แยกด้วยรูปทรง ไม่ใช่สีอย่างเดียว (WCAG 1.4.1) + role ให้ screen
|
||||||
|
* reader ประกาศ (error = alert, ที่เหลือ = status polite — WCAG 4.1.3) + w-full ให้ทุก
|
||||||
|
* แถบกว้างเท่ากันทั้งคอลัมน์
|
||||||
|
* prop action = ปุ่มเสริมในแถบ (เช่น "ลองใหม่" ของ bootError/history error)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
error: CircleAlert,
|
||||||
|
warning: TriangleAlert,
|
||||||
|
success: CircleCheck,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function MessageBar({
|
||||||
|
intent,
|
||||||
|
children,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
intent: "error" | "warning" | "success";
|
||||||
|
children: ReactNode;
|
||||||
|
action?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const Icon = ICONS[intent];
|
||||||
|
const tone =
|
||||||
|
intent === "error"
|
||||||
|
? "border-error/30 bg-error/10 text-error-ink"
|
||||||
|
: intent === "warning"
|
||||||
|
? "border-warning/30 bg-warning/15 text-warning-ink"
|
||||||
|
: "border-success/30 bg-success/10 text-success-ink";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role={intent === "error" ? "alert" : "status"}
|
||||||
|
className={`flex w-full items-start gap-2 rounded-lg border px-3 py-2 text-sm ${tone}`}
|
||||||
|
>
|
||||||
|
<Icon className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||||
|
<div className="min-w-0 flex-1 break-words">{children}</div>
|
||||||
|
{action ? <div className="shrink-0">{action}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
components/wordaddin/SaveControls.tsx
Normal file
68
components/wordaddin/SaveControls.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { CircleCheck, Save, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { MessageBar } from "./MessageBar";
|
||||||
|
import { Spinner } from "./Spinner";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FR-17/FR-18 — ปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] + สถานะการบันทึก (ใช้ทุก phase ตาม spec v10)
|
||||||
|
* banner autoSaveError อยู่ที่นี่เพื่อให้เห็นทุก phase ไม่เฉพาะตอน tracking
|
||||||
|
* พอร์ตจาก SaveControls ใน App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — logic เดิม เปลี่ยนแค่ชั้นภาพ
|
||||||
|
*/
|
||||||
|
export function SaveControls({
|
||||||
|
disabled,
|
||||||
|
busy,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
autoSaveError,
|
||||||
|
closeSupported,
|
||||||
|
onSave,
|
||||||
|
onSaveAndClose,
|
||||||
|
}: {
|
||||||
|
/** กำลังอยู่ใน flow อื่น (versioning) — ปิดปุ่มกันกดซ้อน */
|
||||||
|
disabled: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
status: "saving" | "saved" | null;
|
||||||
|
error: string | null;
|
||||||
|
autoSaveError: string | null;
|
||||||
|
closeSupported: boolean;
|
||||||
|
onSave: () => void;
|
||||||
|
onSaveAndClose: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
disabled={disabled || busy}
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
|
<Save className="size-4" aria-hidden />
|
||||||
|
บันทึก
|
||||||
|
</button>
|
||||||
|
{closeSupported ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
disabled={disabled || busy}
|
||||||
|
onClick={onSaveAndClose}
|
||||||
|
>
|
||||||
|
<X className="size-4" aria-hidden />
|
||||||
|
บันทึกและปิดเอกสาร
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{busy && status === "saving" ? <Spinner label="กำลังบันทึก…" /> : null}
|
||||||
|
{!busy && status === "saved" ? (
|
||||||
|
<p className="inline-flex items-center gap-1.5 text-sm text-ink-soft">
|
||||||
|
<CircleCheck className="size-4 text-success-ink" aria-hidden />
|
||||||
|
บันทึกแล้ว
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{error ? <MessageBar intent="error">{error}</MessageBar> : null}
|
||||||
|
{autoSaveError ? <MessageBar intent="warning">{autoSaveError}</MessageBar> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SaveControls;
|
||||||
13
components/wordaddin/Spinner.tsx
Normal file
13
components/wordaddin/Spinner.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
|
||||||
|
/** Genesis Spinner — แทน Spinner ของ Fluent UI v9 ต้นฉบับ (ใช้ไอคอนหมุนของ lucide)
|
||||||
|
* role="status" ให้ progressMessage ที่เปลี่ยนตลอด flow ถูกประกาศแบบ polite (WCAG 4.1.3) */
|
||||||
|
export function Spinner({ label, className = "" }: { label?: string; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span role="status" className={`inline-flex items-center gap-2 text-sm text-ink-soft ${className}`}>
|
||||||
|
<LoaderCircle className="size-4 animate-spin" aria-hidden />
|
||||||
|
{label ? <span>{label}</span> : null}
|
||||||
|
{!label ? <span className="sr-only">กำลังโหลด…</span> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
579
components/wordaddin/TaskpaneApp.tsx
Normal file
579
components/wordaddin/TaskpaneApp.tsx
Normal file
|
|
@ -0,0 +1,579 @@
|
||||||
|
/* global Office */
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { FileUp, GitCommitHorizontal, LogOut } from "lucide-react";
|
||||||
|
|
||||||
|
import { autoSaveService } from "@/wordaddin/services/autoSaveService";
|
||||||
|
import * as authService from "@/wordaddin/services/authService";
|
||||||
|
import { AuthRequiredError } from "@/wordaddin/services/apiClient";
|
||||||
|
import * as documentService from "@/wordaddin/services/documentService";
|
||||||
|
import { getErrorMessage } from "@/wordaddin/services/errors";
|
||||||
|
import * as metadataService from "@/wordaddin/services/metadataService";
|
||||||
|
import { getDownloadLink, getVersions } from "@/wordaddin/services/versioningService";
|
||||||
|
import { saveAndVersion, startVersioning, VersionSavedButCloseFailedError } from "@/wordaddin/services/versionFlow";
|
||||||
|
import { VersionRecord } from "@/wordaddin/models/versionRecord";
|
||||||
|
|
||||||
|
import ConfirmVersionDialog from "./ConfirmVersionDialog";
|
||||||
|
import { MessageBar } from "./MessageBar";
|
||||||
|
import SaveControls from "./SaveControls";
|
||||||
|
import { Spinner } from "./Spinner";
|
||||||
|
import VersionHistoryPanel from "./VersionHistoryPanel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task pane หลัก — state machine ตาม Phase 5:
|
||||||
|
* checking → (ไม่มี token) signed-out
|
||||||
|
* → (มี token + ไม่ใช่ tracked document) untracked
|
||||||
|
* → (มี token + tracked document) tracking
|
||||||
|
* พอร์ตจาก App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — state/handlers คงเดิมทุกตัว
|
||||||
|
* เปลี่ยนเฉพาะ: import path, JSX → Genesis/Tailwind, ถอด FluentProvider/makeStyles,
|
||||||
|
* เพิ่ม prop demo (โหมด ?addindemo ของ WordAddinGate — ตั้ง phase ตรง ไม่ detect จริง)
|
||||||
|
*
|
||||||
|
* กติกา: ทุก async ต้อง catch — AuthRequiredError → กลับ signed-out อย่างเนียน (AC-9)
|
||||||
|
* error อื่นแสดง MessageBar ไม่ปล่อย unhandled rejection
|
||||||
|
*
|
||||||
|
* FR-17 — auto-save (local เท่านั้น) เริ่มตอน mount ไม่รอ sign-in; FR-3 reminder
|
||||||
|
* กลายเป็น flag `uncommittedSinceVersion` ที่ periodic tick ของ autoSaveService ตั้งค่า
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Phase = "checking" | "signed-out" | "untracked" | "tracking";
|
||||||
|
|
||||||
|
const BTN_PRIMARY =
|
||||||
|
"focus-ring inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50";
|
||||||
|
|
||||||
|
/** demo mode — version ตัวอย่าง 3 รายการ (ใหม่ → เก่า) ให้ history panel มีของโชว์ทันที */
|
||||||
|
function demoVersions(now: number): VersionRecord[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
versionId: "demo-v3",
|
||||||
|
versionNumber: 3,
|
||||||
|
createdAt: new Date(now - 3_600_000).toISOString(),
|
||||||
|
createdByUserId: authService.getUsername() ?? "tester01",
|
||||||
|
contentHash: "demo",
|
||||||
|
message: "ปรับคำร้องตามคำแนะนำอัยการ",
|
||||||
|
hasPdf: true,
|
||||||
|
fields: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
versionId: "demo-v2",
|
||||||
|
versionNumber: 2,
|
||||||
|
createdAt: new Date(now - 86_400_000).toISOString(),
|
||||||
|
createdByUserId: authService.getUsername() ?? "tester01",
|
||||||
|
contentHash: "demo",
|
||||||
|
message: null,
|
||||||
|
hasPdf: true,
|
||||||
|
fields: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
versionId: "demo-v1",
|
||||||
|
versionNumber: 1,
|
||||||
|
createdAt: new Date(now - 172_800_000).toISOString(),
|
||||||
|
createdByUserId: authService.getUsername() ?? "tester01",
|
||||||
|
contentHash: "demo",
|
||||||
|
message: "สร้างเอกสารครั้งแรก",
|
||||||
|
hasPdf: false,
|
||||||
|
fields: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => {
|
||||||
|
const [phase, setPhase] = useState<Phase>(demo === "untracked" ? "untracked" : demo ? "tracking" : "checking");
|
||||||
|
const [bootError, setBootError] = useState<string | null>(null);
|
||||||
|
const [username, setUsername] = useState<string | null>(demo ? (authService.getUsername() ?? null) : null);
|
||||||
|
|
||||||
|
const [documentId, setDocumentId] = useState<string | null>(demo ? "00000000-0000-0000-0000-00000000demo" : null);
|
||||||
|
/** FR-16 — template ต้นทางของไฟล์ร่าง (มีเฉพาะไฟล์ที่เว็บสร้างแบบ draft และยังไม่ได้ track) */
|
||||||
|
const [documentTemplateId, setDocumentTemplateId] = useState<string | null>(null);
|
||||||
|
const [versions, setVersions] = useState<VersionRecord[]>(demo ? demoVersions(Date.now()) : []);
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
|
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [signingIn, setSigningIn] = useState(false);
|
||||||
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [starting, setStarting] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [progressMessage, setProgressMessage] = useState<string | null>(null);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [startConfirmOpen, setStartConfirmOpen] = useState(false);
|
||||||
|
const [flowError, setFlowError] = useState<string | null>(null);
|
||||||
|
const [flowWarning, setFlowWarning] = useState<string | null>(null);
|
||||||
|
const [downloadingPdfVersionId, setDownloadingPdfVersionId] = useState<string | null>(null);
|
||||||
|
const [flowSuccess, setFlowSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [downloadingVersionId, setDownloadingVersionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/** FR-18 — platform นี้ปิดเอกสารจาก add-in ได้ไหม (Word on the web ใช้ close() ไม่ได้ → ซ่อนปุ่มทุกแบบ)
|
||||||
|
* นอก Word (SSR/browser ธรรมดา) canCloseDocument catch คืน false เสมอ */
|
||||||
|
const [closeSupported] = useState(() => documentService.canCloseDocument());
|
||||||
|
/** FR-18 — สถานะของปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] (แยกจาก flow versioning) */
|
||||||
|
const [saveBusy, setSaveBusy] = useState(false);
|
||||||
|
const [saveStatus, setSaveStatus] = useState<"saving" | "saved" | null>(null);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
/** FR-17 — banner เตือนเมื่อ auto-save ล้ม (ไม่บล็อก แต่ไม่ปล่อยเงียบ) */
|
||||||
|
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
|
||||||
|
/** FR-3 — periodic tick เห็นการแก้ไขหลัง version ล่าสุด (แสดงเฉพาะตอน tracking) */
|
||||||
|
const [hasUncommitted, setHasUncommitted] = useState(false);
|
||||||
|
|
||||||
|
/** 401 ที่ refresh ไม่ช่วย → กลับหน้า sign-in อย่างเนียน (AC-9) */
|
||||||
|
const handleAuthRequired = useCallback((error: unknown): boolean => {
|
||||||
|
if (error instanceof AuthRequiredError) {
|
||||||
|
setPhase("signed-out");
|
||||||
|
setDocumentId(null);
|
||||||
|
setDocumentTemplateId(null);
|
||||||
|
setVersions([]);
|
||||||
|
setLoginError("เซสชันหมดอายุ — กรุณา login ใหม่");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshHistory = 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleAuthRequired],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** ตรวจตอนเปิด task pane (FR-1): token → SavedByAddIn → history
|
||||||
|
* FR-16 — อ่าน properties ครั้งเดียวทั้งชุดแล้ว derive tracked เอง เพราะไฟล์ร่าง (untracked)
|
||||||
|
* ต้องได้อ่าน TemplateId ไปใช้ตอน Start Versioning ด้วย (ข้ามทั้งหมดในโหมด demo)
|
||||||
|
* e-Court pass-through — ยังไม่มี session แต่ไฟล์ stamp token/owner มา = ผ่านเข้าเลยไม่ต้องกด login */
|
||||||
|
const detect = useCallback(async () => {
|
||||||
|
setPhase("checking");
|
||||||
|
setBootError(null);
|
||||||
|
try {
|
||||||
|
let properties: metadataService.TrackedDocumentProperties | null = null;
|
||||||
|
if (!(await authService.getAccessToken())) {
|
||||||
|
// e-Court pass-through — อ่านไฟล์ก่อนตัดสิน: ไฟล์ที่ stamp token/owner มา = ผ่านเลยไม่ต้องกด login
|
||||||
|
// (นอก Word เช่น browser ธรรมดา → อ่านไม่ได้ = ไป signed-out ตามเดิม)
|
||||||
|
try {
|
||||||
|
properties = await metadataService.readTrackedProperties();
|
||||||
|
} catch {
|
||||||
|
properties = null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!properties ||
|
||||||
|
!authService.signInFromDocument({ token: properties.ecourtToken, owner: properties.ecourtOwner })
|
||||||
|
) {
|
||||||
|
setPhase("signed-out");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
authService.startAutoRefresh();
|
||||||
|
setUsername(authService.getUsername() ?? null);
|
||||||
|
|
||||||
|
properties ??= await metadataService.readTrackedProperties();
|
||||||
|
const tracked = properties.savedByAddIn === metadataService.ADDIN_SIGNATURE_GUID;
|
||||||
|
const id = tracked ? properties.documentTrackingId : undefined;
|
||||||
|
if (!id) {
|
||||||
|
// ไม่เคย track / track แต่ไม่มี id (ไฟล์เสียหาย) — ไฟล์ร่างจาก template อยู่กลุ่มนี้ (FR-16)
|
||||||
|
setDocumentTemplateId(properties.templateId ?? null);
|
||||||
|
setPhase("untracked");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDocumentId(id);
|
||||||
|
setDocumentTemplateId(null);
|
||||||
|
setPhase("tracking");
|
||||||
|
await refreshHistory(id);
|
||||||
|
} catch (error) {
|
||||||
|
setBootError(getErrorMessage(error, "ตรวจสอบเอกสารไม่สำเร็จ"));
|
||||||
|
}
|
||||||
|
}, [refreshHistory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (demo) return; // ?addindemo — ตั้ง phase จาก prop แล้ว ไม่ตรวจไฟล์จริง
|
||||||
|
void detect();
|
||||||
|
}, [demo, detect]);
|
||||||
|
|
||||||
|
/** FR-17 — เริ่ม auto-save ตั้งแต่ mount ไม่รอ sign-in (ข้ามใน demo กัน error banner จาก Word ที่ไม่มี)
|
||||||
|
* ผูก listener ให้ React state: error → banner, dirty→save → flag FR-3 */
|
||||||
|
useEffect(() => {
|
||||||
|
autoSaveService.onError = (error) =>
|
||||||
|
setAutoSaveError(getErrorMessage(error, "บันทึกอัตโนมัติไม่สำเร็จ — ลองกด \"บันทึก\" ด้วยตนเอง"));
|
||||||
|
autoSaveService.onSaved = () => setAutoSaveError(null);
|
||||||
|
autoSaveService.onStateChange = () => setHasUncommitted(autoSaveService.hasUncommittedSinceVersion);
|
||||||
|
if (!demo) {
|
||||||
|
autoSaveService.start();
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
autoSaveService.stop();
|
||||||
|
autoSaveService.onError = undefined;
|
||||||
|
autoSaveService.onSaved = undefined;
|
||||||
|
autoSaveService.onStateChange = undefined;
|
||||||
|
};
|
||||||
|
}, [demo]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
setDocumentTemplateId(null);
|
||||||
|
setVersions([]);
|
||||||
|
setLoginError(null);
|
||||||
|
setPhase("signed-out");
|
||||||
|
};
|
||||||
|
|
||||||
|
/** FR-18 [บันทึก] — local save เดี่ยวผ่าน lock ของ autoSaveService (ไม่ stamp metadata อะไร) */
|
||||||
|
const handleSaveOnly = async () => {
|
||||||
|
setSaveBusy(true);
|
||||||
|
setSaveStatus("saving");
|
||||||
|
setSaveError(null);
|
||||||
|
setAutoSaveError(null);
|
||||||
|
try {
|
||||||
|
await autoSaveService.save();
|
||||||
|
setSaveStatus("saved");
|
||||||
|
} catch (error) {
|
||||||
|
setSaveStatus(null);
|
||||||
|
setSaveError(getErrorMessage(error, "บันทึกไม่สำเร็จ กรุณาลองอีกครั้ง"));
|
||||||
|
} finally {
|
||||||
|
setSaveBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** FR-18 [บันทึกและปิดเอกสาร] — ลำดับตาม spec: suspend → forceSave → close(Save) → resume
|
||||||
|
* save ล้ม = ไม่ปิด (AC-21) · ปิดสำเร็จแล้ว task pane จะปิดตามเอกสารเอง ไม่มี toast ท้าย */
|
||||||
|
const handleSaveAndClose = async () => {
|
||||||
|
setSaveBusy(true);
|
||||||
|
setSaveStatus("saving");
|
||||||
|
setSaveError(null);
|
||||||
|
setAutoSaveError(null);
|
||||||
|
try {
|
||||||
|
await autoSaveService.suspend();
|
||||||
|
try {
|
||||||
|
await autoSaveService.forceSave();
|
||||||
|
await documentService.closeDocument(documentService.DocumentCloseMode.Save);
|
||||||
|
} finally {
|
||||||
|
autoSaveService.resume();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSaveStatus(null);
|
||||||
|
setSaveError(getErrorMessage(error, "บันทึก/ปิดเอกสารไม่สำเร็จ กรุณาลองอีกครั้ง"));
|
||||||
|
} finally {
|
||||||
|
setSaveBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) · message คือ commit message จาก dialog (FR-13)
|
||||||
|
* FR-16 — ไฟล์ร่าง: แนบ templateId ไปให้ backend ผูก Document กับ template ต้นทาง */
|
||||||
|
const handleStartVersioning = async (message: string) => {
|
||||||
|
setStarting(true);
|
||||||
|
setFlowError(null);
|
||||||
|
setFlowSuccess(null);
|
||||||
|
setFlowWarning(null);
|
||||||
|
try {
|
||||||
|
const result = await startVersioning(message, documentTemplateId ?? undefined, setProgressMessage);
|
||||||
|
setDocumentId(result.documentId);
|
||||||
|
setDocumentTemplateId(null);
|
||||||
|
setPhase("tracking");
|
||||||
|
setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`);
|
||||||
|
if (!result.hasPdf)
|
||||||
|
setFlowWarning(
|
||||||
|
"บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)",
|
||||||
|
);
|
||||||
|
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
|
||||||
|
* message คือ commit message จาก dialog (FR-13)
|
||||||
|
* FR-18 — closeAfter=true มาจาก submit ที่สองของ dialog: version สำเร็จ → ปิดเอกสารตาม */
|
||||||
|
const handleSaveAndVersion = async (message: string, closeAfter = false) => {
|
||||||
|
if (!documentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setFlowError(null);
|
||||||
|
setFlowSuccess(null);
|
||||||
|
setFlowWarning(null);
|
||||||
|
try {
|
||||||
|
const latestVersionNumber = versions[0]?.versionNumber ?? 0;
|
||||||
|
const result = await saveAndVersion(documentId, latestVersionNumber, message, setProgressMessage, closeAfter);
|
||||||
|
if (closeAfter) {
|
||||||
|
// ปิดเอกสารสำเร็จแล้ว — task pane จะปิดตามเอกสาร ไม่ต้อง refresh อะไรต่อ
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFlowSuccess(`สร้าง version ${result.versionNumber} เรียบร้อย`);
|
||||||
|
if (!result.hasPdf)
|
||||||
|
setFlowWarning(
|
||||||
|
"บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)",
|
||||||
|
);
|
||||||
|
await refreshHistory(documentId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof VersionSavedButCloseFailedError) {
|
||||||
|
// version ปลอดภัยบน backend แล้ว — เอกสารยังเปิด แจ้งเป็น warning ไม่ใช่ error ของ version
|
||||||
|
setFlowWarning(error.message);
|
||||||
|
await refreshHistory(documentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!handleAuthRequired(error)) {
|
||||||
|
setFlowError(getErrorMessage(error, "สร้าง version ไม่สำเร็จ"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setProgressMessage(null);
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FR-4 — ดาวน์โหลด version ที่เลือก: ขอ presigned URL อายุสั้นจาก backend แล้วเปิดใน
|
||||||
|
* เบราว์เซอร์ของเครื่อง (webview ของ Word — โดยเฉพาะ WKWebView บน Mac — ทิ้ง blob download
|
||||||
|
* แบบเงียบๆ ไฟล์ไม่เคยถูกเขียนที่ไหนเลย)
|
||||||
|
*/
|
||||||
|
const handleDownload = async (version: VersionRecord, format: "docx" | "pdf" = "docx") => {
|
||||||
|
if (!documentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const setDownloading = format === "pdf" ? setDownloadingPdfVersionId : setDownloadingVersionId;
|
||||||
|
setDownloading(version.versionId);
|
||||||
|
setFlowError(null);
|
||||||
|
try {
|
||||||
|
const link = await getDownloadLink(documentId, version.versionId, format);
|
||||||
|
if (typeof Office.context.ui.openBrowserWindow === "function") {
|
||||||
|
Office.context.ui.openBrowserWindow(link.url);
|
||||||
|
setFlowSuccess(
|
||||||
|
format === "pdf"
|
||||||
|
? `กำลังเปิด PDF ของ v${version.versionNumber} ในเบราว์เซอร์ของคุณ`
|
||||||
|
: `กำลังดาวน์โหลด v${version.versionNumber} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error("Word นี้ไม่รองรับการเปิดเบราว์เซอร์ กรุณาเปิดไฟล์จาก Web Portal");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!handleAuthRequired(error)) {
|
||||||
|
setFlowError(getErrorMessage(error, "ดาวน์โหลด version ไม่สำเร็จ"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDownloading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col gap-2 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-line pb-2">
|
||||||
|
<span className="font-display text-sm font-semibold leading-tight text-ink">e-Court Documents Versioning</span>
|
||||||
|
{phase !== "signed-out" && phase !== "checking" && username ? (
|
||||||
|
/* จอแคบ: username ย้ายไป title/aria-label — ไม่กินพื้นที่แถบหัวจนตัวชื่อแอป wrap 3-4 บรรทัด */
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="focus-ring inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1.5 text-xs text-ink-soft transition hover:text-primary"
|
||||||
|
onClick={handleSignOut}
|
||||||
|
aria-label={`ออกจากระบบ (${username})`}
|
||||||
|
title={`${username} — ออกจากระบบ`}
|
||||||
|
>
|
||||||
|
<LogOut className="size-3.5" aria-hidden />
|
||||||
|
ออกจากระบบ
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{phase === "checking" ? (
|
||||||
|
<div className="flex flex-col items-center gap-4 pt-10">
|
||||||
|
<Spinner label="กำลังตรวจสอบเอกสาร…" />
|
||||||
|
{bootError ? (
|
||||||
|
<MessageBar
|
||||||
|
intent="error"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
|
||||||
|
onClick={() => void detect()}
|
||||||
|
>
|
||||||
|
ลองใหม่
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{bootError}
|
||||||
|
</MessageBar>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{phase === "signed-out" ? (
|
||||||
|
<div className="flex flex-col gap-2 pt-4">
|
||||||
|
<p className="text-base text-ink">เข้าสู่ระบบเพื่อเริ่มใช้งาน versioning</p>
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
ใช้บัญชีของ realm <code>word-versioning</code> — หน้าต่าง login จะเปิดแยกต่างหาก
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN_PRIMARY} mt-2 w-full justify-center`}
|
||||||
|
disabled={signingIn}
|
||||||
|
onClick={() => void handleSignIn()}
|
||||||
|
>
|
||||||
|
{signingIn ? "กำลังเข้าสู่ระบบ…" : "เข้าสู่ระบบ"}
|
||||||
|
</button>
|
||||||
|
{loginError ? <MessageBar intent="warning">{loginError}</MessageBar> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{phase === "untracked" ? (
|
||||||
|
<div className="flex flex-col gap-2 pt-4">
|
||||||
|
<p className="text-base text-ink">เอกสารนี้ยังไม่ได้ติดตาม version</p>
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1
|
||||||
|
(ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)
|
||||||
|
</p>
|
||||||
|
{documentTemplateId ? (
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
ไฟล์นี้เป็นไฟล์ร่างจาก template — กดเริ่มติดตามแล้วระบบจะผูกเอกสารกับ template ต้นทาง
|
||||||
|
และ sync ค่า fields ให้อัตโนมัติ
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN_PRIMARY} mt-2 w-full justify-center`}
|
||||||
|
disabled={starting}
|
||||||
|
onClick={() => setStartConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
<FileUp className="size-4" aria-hidden />
|
||||||
|
{starting ? "กำลังเริ่มติดตาม…" : "Start Versioning This Document"}
|
||||||
|
</button>
|
||||||
|
<SaveControls
|
||||||
|
disabled={starting}
|
||||||
|
busy={saveBusy}
|
||||||
|
status={saveStatus}
|
||||||
|
error={saveError}
|
||||||
|
autoSaveError={autoSaveError}
|
||||||
|
closeSupported={closeSupported}
|
||||||
|
onSave={() => void handleSaveOnly()}
|
||||||
|
onSaveAndClose={() => void handleSaveAndClose()}
|
||||||
|
/>
|
||||||
|
{starting && progressMessage ? <Spinner label={progressMessage} /> : null}
|
||||||
|
{flowError ? <MessageBar intent="error">{flowError}</MessageBar> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{phase === "tracking" ? (
|
||||||
|
/* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (MessageBar/history list ไม่เต็มความกว้าง pane) */
|
||||||
|
<div className="flex flex-col gap-2 pt-4">
|
||||||
|
{/* กลุ่มข้อความสถานะ */}
|
||||||
|
{hasUncommitted ? (
|
||||||
|
<MessageBar intent="warning">
|
||||||
|
มีการแก้ไขที่ยังไม่ถูกบันทึกเป็นเวอร์ชันใหม่ — กด Save & Version เพื่อบันทึกเป็น version ใหม่
|
||||||
|
</MessageBar>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{flowWarning ? <MessageBar intent="warning">{flowWarning}</MessageBar> : null}
|
||||||
|
{flowSuccess ? <MessageBar intent="success">{flowSuccess}</MessageBar> : null}
|
||||||
|
{flowError ? <MessageBar intent="error">{flowError}</MessageBar> : null}
|
||||||
|
|
||||||
|
{/* กลุ่ม action บันทึก — ปุ่มหลักเต็มความกว้างแถบ (action เด่นที่สุดของทั้งแอป) + progress + local save
|
||||||
|
icon ใช้ commit ไม่ใช่ floppy — แยกจากปุ่ม "บันทึก" (local save) ที่ความหมายต่างกัน */}
|
||||||
|
<div className="flex flex-col gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN_PRIMARY} w-full justify-center px-5 py-3`}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
<GitCommitHorizontal className="size-4" aria-hidden />
|
||||||
|
Save & Version
|
||||||
|
</button>
|
||||||
|
{saving && progressMessage ? <Spinner label={progressMessage} /> : null}
|
||||||
|
<SaveControls
|
||||||
|
disabled={saving}
|
||||||
|
busy={saveBusy}
|
||||||
|
status={saveStatus}
|
||||||
|
error={saveError}
|
||||||
|
autoSaveError={autoSaveError}
|
||||||
|
closeSupported={closeSupported}
|
||||||
|
onSave={() => void handleSaveOnly()}
|
||||||
|
onSaveAndClose={() => void handleSaveAndClose()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ink-soft (ไม่ใช่ ink-faint 2.6:1) — tracking id คือ identifier ที่ผู้ใช้อาจต้องอ่าน/คัดลอกตอนแจ้งปัญหา */}
|
||||||
|
<p className="pt-2 text-xs text-ink-soft">tracking id: {documentId?.slice(0, 8)}…</p>
|
||||||
|
|
||||||
|
<VersionHistoryPanel
|
||||||
|
versions={versions}
|
||||||
|
loading={historyLoading}
|
||||||
|
error={historyError}
|
||||||
|
downloadingVersionId={downloadingVersionId}
|
||||||
|
downloadingPdfVersionId={downloadingPdfVersionId}
|
||||||
|
onDownloadPdf={(version) => void handleDownload(version, "pdf")}
|
||||||
|
onDownload={(version) => void handleDownload(version)}
|
||||||
|
onRetry={() => documentId && void refreshHistory(documentId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* กดยืนยันแล้วปิด dialog ทันที — progress/ผลลัพธ์แสดงที่ pane (dialog ค้าง = บังผลลัพธ์
|
||||||
|
+ เสี่ยงกดยืนยันซ้ำสร้าง version ซ้ำ; busy ใน dialog เหลือเป็น defense ชั่วขณะก่อน re-render) */}
|
||||||
|
<ConfirmVersionDialog
|
||||||
|
open={confirmOpen}
|
||||||
|
busy={saving}
|
||||||
|
onConfirm={(message) => {
|
||||||
|
setConfirmOpen(false);
|
||||||
|
void handleSaveAndVersion(message);
|
||||||
|
}}
|
||||||
|
{...(closeSupported
|
||||||
|
? {
|
||||||
|
onConfirmClose: (message: string) => {
|
||||||
|
setConfirmOpen(false);
|
||||||
|
void handleSaveAndVersion(message, true);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
|
onDismiss={() => setConfirmOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* FR-13 — Start Versioning ใช้ dialog เดียวกัน: ยืนยัน + พิมพ์ commit message ของ v1 ก่อนเริ่ม track */}
|
||||||
|
<ConfirmVersionDialog
|
||||||
|
open={startConfirmOpen}
|
||||||
|
busy={starting}
|
||||||
|
title="เริ่มติดตามเอกสารนี้?"
|
||||||
|
description={
|
||||||
|
documentTemplateId
|
||||||
|
? "ระบบจะ stamp metadata ลงไฟล์ บันทึกไฟล์ปัจจุบันเป็น version 1 และผูกเอกสารกับ template ต้นทาง (ไฟล์ร่าง)"
|
||||||
|
: "ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้"
|
||||||
|
}
|
||||||
|
confirmLabel="เริ่มติดตาม (version 1)"
|
||||||
|
onConfirm={(message) => {
|
||||||
|
setStartConfirmOpen(false);
|
||||||
|
void handleStartVersioning(message);
|
||||||
|
}}
|
||||||
|
onDismiss={() => setStartConfirmOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TaskpaneApp;
|
||||||
149
components/wordaddin/VersionHistoryPanel.tsx
Normal file
149
components/wordaddin/VersionHistoryPanel.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { Download, FileText, LoaderCircle } from "lucide-react";
|
||||||
|
|
||||||
|
import { VersionRecord } from "@/wordaddin/models/versionRecord";
|
||||||
|
|
||||||
|
import { MessageBar } from "./MessageBar";
|
||||||
|
import { Spinner } from "./Spinner";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FR-4 — แสดง version history (เลข version / เวลา / ผู้สร้าง) + ปุ่มดาวน์โหลด version นั้น
|
||||||
|
* ชั้นภาพ: timeline แนวตั้ง — จุดโหนดเท่าทึบคือเวอร์ชันล่าสุด จุดกลวงคือเก่ากว่า
|
||||||
|
* (เลข version เป็น static → ใช้ ink ตามกฎ "blue เฉพาะ interactive" ของ DESIGN.md)
|
||||||
|
*/
|
||||||
|
|
||||||
|
function formatTimestamp(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
return Number.isNaN(date.getTime())
|
||||||
|
? iso
|
||||||
|
: date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** px-2 py-1.5 + text-xs ≈ สูง 30px — พ้นเกณฑ์ touch target 24px ของ WCAG 2.5.8 โดยยัง dense พอสำหรับ list */
|
||||||
|
const BTN_ACTION =
|
||||||
|
"focus-ring inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs text-ink-soft transition hover:bg-primary/[0.06] hover:text-primary disabled:pointer-events-none disabled:opacity-50";
|
||||||
|
|
||||||
|
export function VersionHistoryPanel({
|
||||||
|
versions,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
downloadingVersionId,
|
||||||
|
downloadingPdfVersionId,
|
||||||
|
onDownloadPdf,
|
||||||
|
onDownload,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
versions: VersionRecord[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
/** versionId ของ version ที่กำลังดาวน์โหลดอยู่ (แสดง spinner ที่ปุ่ม) */
|
||||||
|
downloadingVersionId: string | null;
|
||||||
|
downloadingPdfVersionId: string | null;
|
||||||
|
onDownloadPdf: (version: VersionRecord) => void;
|
||||||
|
onDownload: (version: VersionRecord) => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
}) {
|
||||||
|
const anyDownloading = downloadingVersionId !== null || downloadingPdfVersionId !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="mt-4 mb-2 flex items-baseline justify-between">
|
||||||
|
<h3 className="font-display text-sm font-semibold text-ink">Version history</h3>
|
||||||
|
{versions.length > 0 ? <span className="text-xs text-ink-soft">{versions.length} เวอร์ชัน</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? <Spinner label="กำลังโหลด…" /> : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<MessageBar
|
||||||
|
intent="error"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
ลองใหม่
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</MessageBar>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && !error && versions.length === 0 ? (
|
||||||
|
<p className="text-sm text-ink-soft">ยังไม่มี version ในระบบ</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{versions.length > 0 ? (
|
||||||
|
<ol className="relative">
|
||||||
|
{/* ราง timeline — วาดครั้งเดียวระดับรายการ (เวอร์ชันเดียวไม่ต้องมีเส้น) */}
|
||||||
|
{versions.length > 1 ? (
|
||||||
|
<span className="absolute bottom-2 left-[3.5px] top-2 w-px bg-line" aria-hidden />
|
||||||
|
) : null}
|
||||||
|
{versions.map((version, index) => {
|
||||||
|
const isLatest = index === 0;
|
||||||
|
return (
|
||||||
|
<li className="relative pb-3 pl-4 last:pb-0" key={version.versionId}>
|
||||||
|
<span
|
||||||
|
className={`absolute left-0 top-[5px] size-2 rounded-full ${
|
||||||
|
isLatest ? "bg-ink" : "border border-ink-faint bg-canvas"
|
||||||
|
}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<span className="font-display text-sm font-semibold text-ink">v{version.versionNumber}</span>
|
||||||
|
{isLatest ? (
|
||||||
|
<span className="rounded-full bg-ink/[0.06] px-1.5 py-px text-[11px] text-ink-soft">ล่าสุด</span>
|
||||||
|
) : null}
|
||||||
|
<span className="ml-auto flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={BTN_ACTION}
|
||||||
|
disabled={anyDownloading}
|
||||||
|
aria-busy={downloadingVersionId === version.versionId}
|
||||||
|
onClick={() => onDownload(version)}
|
||||||
|
>
|
||||||
|
{/* คง label เดิม + สลับเป็น spinner — label ยาว "กำลังดาวน์โหลด…" ทำแถว wrap กระโดด */}
|
||||||
|
{downloadingVersionId === version.versionId ? (
|
||||||
|
<LoaderCircle className="size-3.5 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Download className="size-3.5" aria-hidden />
|
||||||
|
)}
|
||||||
|
ดาวน์โหลด
|
||||||
|
</button>
|
||||||
|
{version.hasPdf ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={BTN_ACTION}
|
||||||
|
disabled={anyDownloading}
|
||||||
|
aria-busy={downloadingPdfVersionId === version.versionId}
|
||||||
|
onClick={() => onDownloadPdf(version)}
|
||||||
|
>
|
||||||
|
{downloadingPdfVersionId === version.versionId ? (
|
||||||
|
<LoaderCircle className="size-3.5 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<FileText className="size-3.5" aria-hidden />
|
||||||
|
)}
|
||||||
|
PDF
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{version.message ? (
|
||||||
|
<p className="mt-0.5 whitespace-pre-wrap [overflow-wrap:anywhere] text-xs text-ink">
|
||||||
|
{version.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="mt-0.5 text-xs text-ink-soft">
|
||||||
|
{formatTimestamp(version.createdAt)} · โดย {version.createdByUserId}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VersionHistoryPanel;
|
||||||
131
components/wordaddin/WordAddinGate.tsx
Normal file
131
components/wordaddin/WordAddinGate.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { withBasePath } from "@/utils/base-path";
|
||||||
|
import { signInForDemo } from "@/wordaddin/services/authService";
|
||||||
|
|
||||||
|
import { MessageBar } from "./MessageBar";
|
||||||
|
import { Spinner } from "./Spinner";
|
||||||
|
import TaskpaneApp from "./TaskpaneApp";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate ของ taskpane ใน App Router — ทำหน้าที่แทน Office.onReady ก่อน render ของ webpack
|
||||||
|
* ต้นฉบับ: Office.js โหลดใน <head> ของ taskpane.html แล้ว Office.onReady ก่อน createRoot
|
||||||
|
* (บริบท Next: ฉีด <script> self-host หลัง hydration แทน — ไฟล์ services ทั้งหมดไม่สัมผัส
|
||||||
|
* Office ที่ module top-level จึงปลอดภัย)
|
||||||
|
*
|
||||||
|
* โหมด ?addindemo=untracked|tracking (เฉพาะ dev) — ข้าม office.js ทั้งหมด + sign in mock
|
||||||
|
* ทันที เพื่อดู UI ทุก phase ใน browser ธรรมดา (mock versioningService ทำงานได้เอง
|
||||||
|
* แต่ flow ที่แตะ Word จริงจะจบที่ error ตาม expected — ดู docs/WORDADDIN-DEV.md)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** office.js self-host จาก postinstall scripts/fetch-office-js.mjs (intranet ไม่ใช้ CDN) */
|
||||||
|
const OFFICE_JS_SRC = withBasePath("/wordaddin/office-js/office.js");
|
||||||
|
/** Office.onReady callback ไม่ยิงภายใน 5 วิหลังฉีด script ก็ proceed (กัน webview แปลก ๆ — โหลดสำเร็จแต่ onReady เงียบ) */
|
||||||
|
const ONREADY_FALLBACK_MS = 5_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* โหลด office.js ครั้งเดียวต่อ page เป็น singleton promise ระดับ module —
|
||||||
|
* ห้ามฉีด <script> ซ้ำ: React StrictMode รัน effect สองรอบใน dev และ guard
|
||||||
|
* `typeof Office !== "undefined"` ไม่ครอบช่วงที่ตัวแรกยังโหลดไม่เสร็จ
|
||||||
|
* (โหลดซ้ำ = evaluate ซ้ำ = "Cannot redefine property: context" เพราะ office.js
|
||||||
|
* define `Microsoft.Office.WebExtension.context` แบบ configurable:false)
|
||||||
|
* error → คืน promise ใหม่ครั้งหน้า (ลบ script เก่าทิ้งให้ inject ใหม่ได้)
|
||||||
|
*/
|
||||||
|
let officeReadyPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
function ensureOfficeJs(): Promise<boolean> {
|
||||||
|
if (!officeReadyPromise) {
|
||||||
|
officeReadyPromise = new Promise<boolean>((resolve) => {
|
||||||
|
if (typeof Office !== "undefined") {
|
||||||
|
void Office.onReady(() => resolve(true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const script = document.createElement("script");
|
||||||
|
const fallbackTimer = setTimeout(() => resolve(true), ONREADY_FALLBACK_MS);
|
||||||
|
const onReady = () => {
|
||||||
|
// เคลียร์ timer เฉพาะเมื่อ callback ของ Office.onReady ยิงจริง — ถ้าเคลียร์ตั้งแต่ onload
|
||||||
|
// แล้ว onReady เงียบ fallback จะตายไปก่อนทำหน้าที่ (ค้าง loading ตลอดไป)
|
||||||
|
void Office.onReady(() => {
|
||||||
|
clearTimeout(fallbackTimer);
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
script.onload = onReady;
|
||||||
|
script.onerror = () => {
|
||||||
|
clearTimeout(fallbackTimer);
|
||||||
|
officeReadyPromise = null;
|
||||||
|
script.remove();
|
||||||
|
resolve(false);
|
||||||
|
};
|
||||||
|
script.src = OFFICE_JS_SRC;
|
||||||
|
script.async = false; // ให้ script นี้ได้ทำงานตามลำดับก่อนโค้ดที่แตะ Office
|
||||||
|
script.id = "office-js";
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return officeReadyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GateStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
export function WordAddinGate() {
|
||||||
|
const [status, setStatus] = useState<GateStatus>("loading");
|
||||||
|
const [demo, setDemo] = useState<"untracked" | "tracking" | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const demoParam = new URLSearchParams(window.location.search).get("addindemo");
|
||||||
|
if (process.env.NODE_ENV !== "production" && (demoParam === "untracked" || demoParam === "tracking")) {
|
||||||
|
signInForDemo();
|
||||||
|
setDemo(demoParam);
|
||||||
|
setStatus("ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void ensureOfficeJs()
|
||||||
|
.then((ok) => {
|
||||||
|
if (!cancelled) setStatus(ok ? "ready" : "error");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Office.onReady throw sync ใน executor กลายเป็น rejection — ไป error screen แทนค้าง loading
|
||||||
|
if (!cancelled) setStatus("error");
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<Spinner label="กำลังเริ่มต้น…" className="text-ink-soft" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "error") {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-4">
|
||||||
|
<MessageBar
|
||||||
|
intent="error"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
ลองใหม่
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
โหลด Office.js ไม่สำเร็จ — เช็คว่า dev server รันอยู่และไฟล์ public/wordaddin/office-js/office.js
|
||||||
|
ถูกสร้างแล้ว (npm install รัน postinstall scripts/fetch-office-js.mjs)
|
||||||
|
</MessageBar>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <TaskpaneApp demo={demo ?? undefined} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WordAddinGate;
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
FROM node:24-alpine AS deps
|
FROM node:24-alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
# postinstall รัน scripts/patch-pdf-worker.mjs + scripts/copy-swagger-ui.mjs ตอน npm ci — คัดลอกเข้ามาก่อน ไม่งั้นหาไฟล์ไม่เจอ
|
# postinstall รัน scripts/patch-pdf-worker.mjs + copy-swagger-ui.mjs + fetch-office-js.mjs ตอน npm ci — คัดลอกเข้ามาก่อน ไม่งั้นหาไฟล์ไม่เจอ
|
||||||
COPY scripts/patch-pdf-worker.mjs scripts/copy-swagger-ui.mjs scripts/
|
# (fetch-office-js ดาวน์โหลด Office.js runtime จาก appsforoffice.microsoft.com — build stage ต้องออกอินเทอร์เน็ตถึง CDN นี้ได้)
|
||||||
|
COPY scripts/patch-pdf-worker.mjs scripts/copy-swagger-ui.mjs scripts/fetch-office-js.mjs scripts/
|
||||||
# postinstall เขียน public/note/pdf.worker.min.mjs (script สร้าง dir ปลายทางเอง)
|
# postinstall เขียน public/note/pdf.worker.min.mjs (script สร้าง dir ปลายทางเอง)
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
|
@ -16,7 +17,8 @@ COPY . .
|
||||||
# (postinstall รันใน stage deps ซึ่งยังไม่มี public/ ของ repo) — ต้องใช้ตัว patch ด้วย
|
# (postinstall รันใน stage deps ซึ่งยังไม่มี public/ ของ repo) — ต้องใช้ตัว patch ด้วย
|
||||||
# ไม่ใช่ copyFileSync เปล่า ๆ ไม่งั้น worker ที่ patch fake-space ไทยถูกเขียนทับด้วยตัวดิบ
|
# ไม่ใช่ copyFileSync เปล่า ๆ ไม่งั้น worker ที่ patch fake-space ไทยถูกเขียนทับด้วยตัวดิบ
|
||||||
# + คัดลอก asset Swagger UI (public/note/swagger-ui/) ให้ตรงเวอร์ชันเช่นกัน
|
# + คัดลอก asset Swagger UI (public/note/swagger-ui/) ให้ตรงเวอร์ชันเช่นกัน
|
||||||
RUN node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs
|
# + ดาวน์โหลด Office.js runtime ล่าสุด (public/wordaddin/office-js/) ของ taskpane wordaddin
|
||||||
|
RUN node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs && node scripts/fetch-office-js.mjs
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
# db/index.ts throw ถ้าไม่มี DATABASE_URL ตอน next build เก็บ page data —
|
# db/index.ts throw ถ้าไม่มี DATABASE_URL ตอน next build เก็บ page data —
|
||||||
# ใส่ค่า dummy (postgres.js เชื่อมแบบ lazy ไม่มี query ตอน build) ตัวจริงฉีดตอน docker run
|
# ใส่ค่า dummy (postgres.js เชื่อมแบบ lazy ไม่มี query ตอน build) ตัวจริงฉีดตอน docker run
|
||||||
|
|
|
||||||
65
docs/WORDADDIN-DEV.md
Normal file
65
docs/WORDADDIN-DEV.md
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# Word Add-in (wordaddin) — คู่มือ dev และการทดสอบ
|
||||||
|
|
||||||
|
Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route ของ Next.js ที่ **`/docedit/wordaddin`** (ต้นฉบับ webpack + Fluent UI: `D:\word-addin-poc\addin`) — spec พฤติกรรม UI ทั้งหมดอยู่ที่ [`docs/addin-ui-flow.md`](./addin-ui-flow.md)
|
||||||
|
|
||||||
|
**ทางเข้า:** เปิด `/docedit` เปล่า ๆ จะ redirect มาที่ taskpane ทันที (ไม่ผ่าน login — คำขอผู้ใช้ 2026-10-14) ยกเว้นลิงก์ e-Court `/docedit/?fileUrl=&fileId=&typeId=` ที่ยังส่งต่อให้ `/note` ตามสัญญา N8 · manifest ชี้ `/docedit/wordaddin` ตรง ๆ (ไม่ว่าผ่าน root เพื่อลด hop ใน webview)
|
||||||
|
|
||||||
|
สถานะช่วงนี้: **auth และ API เป็น mock** (login ทันที + version store เก็บ metadata ค้างข้าม session ใน localStorage ของ taskpane เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ — สลับกลับของจริงด้วยการลบ `wordaddin-mock-versions` หรือเคลียร์ storage ของ origin) รอ backend/auth จริง — ดูหัว "การสลับกลับของจริง" ด้านล่าง
|
||||||
|
|
||||||
|
## ไฟล์หลัก
|
||||||
|
|
||||||
|
| ที่ | คือ |
|
||||||
|
|---|---|
|
||||||
|
| `app/wordaddin/page.tsx` + `components/wordaddin/WordAddinGate.tsx` | gate โหลด Office.js (self-host) → `Office.onReady` → mount `TaskpaneApp` |
|
||||||
|
| `components/wordaddin/TaskpaneApp.tsx` | state machine `checking/signed-out/untracked/tracking` + ทุก handler |
|
||||||
|
| `wordaddin/services/*` | ชั้น Office.js พอร์ตดิบจากต้นฉบับ + `authService`/`versioningService` เป็น mock |
|
||||||
|
| `public/wordaddin/manifest.xml` | manifest sideload (URL เป็น absolute https ต่อ environment) |
|
||||||
|
| `public/wordaddin/office-js/` | Office.js runtime — สร้างโดย postinstall `scripts/fetch-office-js.mjs` ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft (**gitignore** — รัน `npm install` ทุกครั้งหลัง clone เครื่องต้องออกอินเทอร์เน็ตตอนติดตั้ง ส่วนตัวระบบ deploy ใช้ intranet ล้วน) |
|
||||||
|
| `wordaddin/config.ts` | env `NEXT_PUBLIC_ADDIN_*` (ตารางด้านล่าง) |
|
||||||
|
|
||||||
|
## Env
|
||||||
|
|
||||||
|
| ตัวแปร | default | ใช้เมื่อไหร่ |
|
||||||
|
|---|---|---|
|
||||||
|
| `NEXT_PUBLIC_ADDIN_API_BASE_URL` | `""` (ว่าง) | ปลด mock ต่อ backend จริง (ต้องเป็น HTTPS) |
|
||||||
|
| `NEXT_PUBLIC_ADDIN_MOCK_USERNAME` | `"tester01"` | ชื่อผู้ใช้ของ mock — แสดง header + เป็น `createdByUserId` |
|
||||||
|
|
||||||
|
## รัน dev ด้วย HTTPS (จำเป็นสำหรับ sideload ใน Word)
|
||||||
|
|
||||||
|
manifest ต้องชี้ https — 2 ทางเลือก:
|
||||||
|
|
||||||
|
1. **self-signed ของ Next (ง่ายสุด):** `npm run dev:https` → https://localhost:3000 — Word อาจเตือน certificate ครั้งแรก (เปิด URL ใน browser กดผ่านก่อน 1 ครั้ง)
|
||||||
|
2. **ใบรับรอง trusted (แนะนำ ไม่มี warning):** รัน `npx office-addin-dev-certs install` ครั้งเดียว แล้ว copy `%USERPROFILE%\.office-addin-dev-certs\localhost.crt` + `localhost.key` มาที่ `certs/` (gitignore แล้ว) แล้วใช้ `npm run dev:https:cert`
|
||||||
|
|
||||||
|
## Sideload ใน Word Desktop (Windows)
|
||||||
|
|
||||||
|
1. รัน dev server (https) แล้วดาวน์โหลด manifest: `https://localhost:3000/docedit/wordaddin/manifest.xml`
|
||||||
|
2. เอาไฟล์ manifest ไว้โฟลเดอร์ใดโฟลเดอร์หนึ่ง เช่น `%USERPROFILE%\Documents\wordaddin-manifest\`
|
||||||
|
3. Word: **File > Options > Trust Center > Trust Center Settings > Trusted Add-in Catalogs** เพิ่มโฟลเดอร์นั้น > restart Word
|
||||||
|
4. **Insert > My Add-ins > SHARED FOLDER** > เปิด "e-Court Documents Versioning"
|
||||||
|
|
||||||
|
Deploy จริง (intranet): rewrite URL ทุกตัวใน manifest จาก `https://localhost:3000/docedit/wordaddin` เป็น `https://<โฮสต์>/docedit/wordaddin` (และ AppDomain + icons ด้วย)
|
||||||
|
|
||||||
|
## ทดสอบใน browser ธรรมดา (ไม่ต้องเปิด Word)
|
||||||
|
|
||||||
|
- **`/docedit/wordaddin`** — เส้นทางจริง: Office.js โหลดได้แม่นนอก host (host เป็น null) → เห็น `signed-out` → กด Sign in (mock ผ่านทันที) → detect พังที่ `Word.run` → `bootError` + ปุ่มลองใหม่ (ตรวจ state machine/handler ได้ครบ)
|
||||||
|
- **`/docedit/wordaddin?addindemo=untracked`** / **`?addindemo=tracking`** — dev-only: ข้าม Office.js + sign in mock ทันที + ตั้ง phase ตามค่า (tracking มี history ตัวอย่าง 3 รายการ) — ทุกปุ่มกดได้ แต่ flow ที่แตะ Word จริง (Start Versioning / Save & Version / บันทึก) จะจบที่ error ตาม expected
|
||||||
|
- หมายเหตุ: ใน browser ปกติ (ไม่ใช่ demo) banner "บันทึกอัตโนมัติไม่สำเร็จ" จะโผล่หลัง ~30 วิ เพราะ auto-save (FR-17) พยายาม save ทุก 30 วิแต่ไม่มี Word — ถือว่าถูกต้อง
|
||||||
|
|
||||||
|
## ทดสอบ e-Court pass-through (ข้าม login)
|
||||||
|
|
||||||
|
ไฟล์ที่ e-Court stamp custom properties `token` และ/หรือ `owner` ไว้ จะเข้าใช้ได้เลยโดยไม่ต้องกด Sign in — `detect()` อ่านค่าทั้งสองตอนเปิด task pane แล้วเรียก `authService.signInFromDocument()` (username ได้จาก `owner` → ถอด `uid` ใน JWT payload → `NEXT_PUBLIC_ADDIN_MOCK_USERNAME`, token จากไฟล์เก็บใน memory ให้ `getAccessToken()` ใช้ต่อ)
|
||||||
|
|
||||||
|
ทดสอบเองใน Word โดยไม่ต้องรอ e-Court: เปิดไฟล์ → **File → Info → Properties → Advanced Properties → แท็บ Custom** → เพิ่ม property ชื่อ `token` (ค่า JWT ตัวอย่าง `eyJ...` — payload มี `uid` ก็แสดงเป็นชื่อผู้ใช้ได้) และ/หรือ `owner` (เช่น `u1001`) → เปิด task pane ใหม่ → ต้องเข้าเฟส untracked/tracking ทันทีไม่ผ่านหน้า login
|
||||||
|
|
||||||
|
## การสลับกลับของจริง (วันหน้า)
|
||||||
|
|
||||||
|
- **Auth:** `wordaddin/services/authService.ts` เป็น mock คง surface เดิมครบ (`login/getAccessToken/refreshAccessToken/getUsername/signOut/startAutoRefresh/...`) — เขียน implementation จริงทับ (ต้นฉบับ Keycloak PKCE + Office Dialog API อยู่ที่ `D:\word-addin-poc\addin\src\services\authService.ts`; ถ้าใช้ ต้องเพิม DialogAPI กลับเข้า manifest + หน้า auth-start/auth-callback) · `signInFromDocument` (pass-through จาก token/owner ในไฟล์) เป็น mock-only — วันหน้า token ในไฟล์ควรเป็นแค่ตัวจับคู่เอกสาร ส่วนสิทธิ์ผู้ใช้พิสูจน์ตอน runtime
|
||||||
|
- **API:** `wordaddin/services/versioningService.ts` เก็บชื่อ/ลายเซ็นเหมือนต้นฉบับเป๊ะ — ก้อบ body เดิม (multipart POST ผ่าน `apiClient` ซึ่งพอร์ตไว้แล้ว) กลับมา + ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL`
|
||||||
|
- ชุด unit test ของ services (jest → vitest) ยังไม่พอร์ต — ต้นฉบับอยู่ที่ `D:\word-addin-poc\addin\src\services\*.test.ts` + mock ที่ `src/test/`
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
ไม่ต้องทำอะไรเพิ่ม — `docker/web.Dockerfile` รัน `scripts/fetch-office-js.mjs` ใน build อยู่แล้ว (build stage ต้องออกอินเทอร์เน็ตถึง `appsforoffice.microsoft.com` ได้) และ Next รวม `public/` ไว้ใน standalone อัตโนมัติ
|
||||||
|
|
||||||
|
> ทำไมต้องเป็นฉบับ CDN ล่าสุด: แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` (ปุ่ม "บันทึกเวอร์ชันและปิดเอกสาร" พัง: `context.document.close is not a function`) และไม่มี enum `Word.CloseBehavior`/`SaveBehavior` ทับ ๆ กับ `@types/office-js` ฉบับใหม่ที่ใช้ compile
|
||||||
274
docs/addin-ui-flow.md
Normal file
274
docs/addin-ui-flow.md
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
# Add-in UI Flow — คู่มือสร้าง UI ในโปรเจกต์ใหม่ (รอ API จริง)
|
||||||
|
|
||||||
|
เอกสารนี้สรุป **เฉพาะฝั่ง UI/flow ของ Word add-in** (`/addin`) ตัดส่วนที่เป็น Office.js mechanics ออกให้เหลือแต่สิ่งที่หน้าตา/พฤติกรรม UI ต้องมี เพื่อเอาไปสร้าง UI ในโปรเจกต์ใหม่แล้ว mock API รอ backend จริง
|
||||||
|
|
||||||
|
ต้นฉบับอ้างอิง (repo เดิมที่ `D:\word-addin-poc\addin`): `src/taskpane/components/App.tsx`, `ConfirmVersionDialog.tsx`, `VersionHistoryPanel.tsx`, `src/services/versionFlow.ts`, `versioningService.ts`, `metadataService.ts`, `autoSaveService.ts`, `src/models/versionRecord.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. State machine หลัก
|
||||||
|
|
||||||
|
Task pane ทั้งหมดเป็น state machine เดียว **ไม่มี routing** — component เดียว (`App`) สลับหน้าตาตาม `phase`:
|
||||||
|
|
||||||
|
```
|
||||||
|
checking ──(ไม่มี token)──────────────→ signed-out
|
||||||
|
│
|
||||||
|
├─(มี token + ไม่ใช่ tracked doc)──→ untracked
|
||||||
|
└─(มี token + tracked doc)────────→ tracking
|
||||||
|
```
|
||||||
|
|
||||||
|
การเปลี่ยน phase อื่นๆ:
|
||||||
|
|
||||||
|
- `signed-out` → (sign in สำเร็จ) → `checking` → ตัดสินใหม่ตามไฟล์ปัจจุบัน
|
||||||
|
- `untracked` → (Start Versioning สำเร็จ) → `tracking`
|
||||||
|
- ทุก phase → (401 ที่ refresh token ไม่ช่วย / กดออกจากระบบ) → `signed-out`
|
||||||
|
- ปุ่ม "ลองใหม่" มีจริงแค่ 2 จุด: `bootError` ใน `checking` และ history error ใน `VersionHistoryPanel` — `untracked`/`tracking` **ไม่มี**ปุ่มลองใหม่แยกต่างหาก (เริ่ม flow ใหม่ด้วยการกดปุ่ม flow ซ้ำ) · กลไกเดียวที่วิ่งกลับมา `checking` คือ sign-in สำเร็จ
|
||||||
|
|
||||||
|
State ที่ UI ถือนอกเหนือจาก phase:
|
||||||
|
|
||||||
|
| State | ความหมาย |
|
||||||
|
|---|---|
|
||||||
|
| `username` | ชื่อผู้ใช้จาก token (แสดงใน header) |
|
||||||
|
| `documentId` | tracking id ของเอกสาร (มีเฉพาะ `tracking`) |
|
||||||
|
| `documentTemplateId` | template ต้นทางของ**ไฟล์ร่าง** (มีเฉพาะ `untracked` ที่เป็นไฟล์ร่าง — FR-16) |
|
||||||
|
| `versions` + `historyLoading` + `historyError` | version history ของ `documentId` |
|
||||||
|
| `signingIn`, `loginError` | สถานะหน้า sign-in |
|
||||||
|
| `starting`, `saving`, `progressMessage` | busy + ข้อความ progress ของ flow Start Versioning / Save & Version |
|
||||||
|
| `confirmOpen`, `startConfirmOpen` | เปิด dialog ยืนยันของ flow ไหน |
|
||||||
|
| `flowError`, `flowWarning`, `flowSuccess` | MessageBar 3 แบบของ flow หลัก (แสดงเฉพาะ `tracking`) |
|
||||||
|
| `downloadingVersionId`, `downloadingPdfVersionId` | versionId ที่กำลังโหลด docx/pdf (spinner ที่ปุ่ม) |
|
||||||
|
| `closeSupported` | platform นี้ปิดเอกสารจาก add-in ได้ไหม — **คุมการแสดงปุ่มปิดเอกสารทุกจุด** (FR-18) |
|
||||||
|
| `saveBusy`, `saveStatus`, `saveError` | สถานะชุดปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] (แยกจาก flow versioning) |
|
||||||
|
| `autoSaveError` | banner เตือนเมื่อ auto-save ล้ม (FR-17) |
|
||||||
|
| `hasUncommitted` | เคยเห็นการแก้ไขหลัง version ล่าสุด → banner เตือน (FR-3) |
|
||||||
|
| `bootError` | error ของการ detect ตอนเปิด task pane (แสดงเฉพาะ `checking` — MessageBar error + ปุ่ม "ลองใหม่" รัน detect ใหม่) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. UI แต่ละ phase
|
||||||
|
|
||||||
|
### Header (ทุก phase)
|
||||||
|
|
||||||
|
- ชื่อแอป: **e-Court Documents Versioning** (semibold)
|
||||||
|
- ฝั่งขวา (เมื่อ login แล้วและไม่ใช่ `checking`/`signed-out`): ปุ่มเล็ก icon + `ออกจากระบบ` — username อยู่ใน `title`/`aria-label` (จอแคบ ไม่กินพื้นที่แถบหัว) — กดแล้ว sign out + กลับ `signed-out`
|
||||||
|
|
||||||
|
### `checking`
|
||||||
|
|
||||||
|
- `Spinner "กำลังตรวจสอบเอกสาร…"` ตรงกลาง
|
||||||
|
- ถ้า detect พัง (`bootError`): MessageBar **error** + ปุ่ม `ลองใหม่` ข้างใน bar (รัน detect ใหม่)
|
||||||
|
|
||||||
|
### `signed-out`
|
||||||
|
|
||||||
|
- ข้อความ: "เข้าสู่ระบบเพื่อเริ่มใช้งาน versioning"
|
||||||
|
- คำอธิบายจางๆ: "ใช้บัญชีของ realm `word-versioning` — หน้าต่าง login จะเปิดแยกต่างหาก"
|
||||||
|
- ปุ่ม primary `เข้าสู่ระบบ` → ระหว่างรันเปลี่ยนเป็น `กำลังเข้าสู่ระบบ…` + disable
|
||||||
|
- ถ้า login พัง: MessageBar **warning** (`loginError`) — เช่น "เซสชันหมดอายุ — กรุณา login ใหม่" ตอนโดน 401 กลาง flow
|
||||||
|
|
||||||
|
> ใน project จริง login เปิดหน้าต่างแยกผ่าน Office Dialog API (ห้าม redirect iframe ไป Keycloak ตรงๆ) — ถ้าโปรเจกต์ใหม่ยังไม่มี auth ให้ mock `login()` คืน `{ ok: true }` ไปก่อน
|
||||||
|
|
||||||
|
### `untracked`
|
||||||
|
|
||||||
|
เรียงจากบนลงล่าง:
|
||||||
|
|
||||||
|
1. ข้อความ: "เอกสารนี้ยังไม่ได้ติดตาม version"
|
||||||
|
2. คำอธิบายจางๆ: "กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1 (ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)"
|
||||||
|
3. **เฉพาะไฟล์ร่าง** (`documentTemplateId` มีค่า): ข้อความจางๆ เพิ่ม — "ไฟล์นี้เป็นไฟล์ร่างจาก template — กดเริ่มติดตามแล้วระบบจะผูกเอกสารกับ template ต้นทาง และ sync ค่า fields ให้อัตโนมัติ"
|
||||||
|
4. ปุ่ม primary `Start Versioning This Document` → ระหว่างรัน: `กำลังเริ่มติดตาม…` + disable
|
||||||
|
5. ชุดปุ่มบันทึก `SaveControls` (ดูหัวข้อ 3) — `disabled` เมื่อ `starting`
|
||||||
|
6. progress: spinner เล็ก + `progressMessage` (เฉพาะตอน `starting`)
|
||||||
|
7. `flowError` → MessageBar **error**
|
||||||
|
|
||||||
|
### `tracking`
|
||||||
|
|
||||||
|
เรียงจากบนลงล่าง:
|
||||||
|
|
||||||
|
1. **เฉพาะ `hasUncommitted`**: MessageBar **warning** — "มีการแก้ไขที่ยังไม่ถูกบันทึกเป็นเวอร์ชันใหม่ — กด Save & Version เพื่อบันทึกเป็น version ใหม่" (FR-3)
|
||||||
|
2. `flowWarning` → MessageBar **warning** (เช่น PDF ไม่สำเร็จ / version สำเร็จแต่ปิดเอกสารไม่สำเร็จ)
|
||||||
|
3. `flowSuccess` → MessageBar **success** (เช่น "สร้าง version 3 เรียบร้อย")
|
||||||
|
4. `flowError` → MessageBar **error**
|
||||||
|
5. ปุ่ม primary **ขนาดใหญ่** `Save & Version` — disable เมื่อ `saving`
|
||||||
|
6. progress: spinner เล็ก + `progressMessage` (เฉพาะตอน `saving`)
|
||||||
|
7. ชุดปุ่มบันทึก `SaveControls` — `disabled` เมื่อ `saving`
|
||||||
|
8. บรรทัดจางๆ: `tracking id: {documentId 8 ตัวแรก}…`
|
||||||
|
9. `VersionHistoryPanel` (ดูหัวข้อ 5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ชุดปุ่มบันทึก `SaveControls` (FR-17/FR-18)
|
||||||
|
|
||||||
|
Component ใช้ร่วมทั้ง `untracked` และ `tracking` — อยู่นอก flow versioning:
|
||||||
|
|
||||||
|
- ปุ่ม `บันทึก` — local save เดี่ยว ไม่แตะ metadata ไม่สร้าง version
|
||||||
|
- ปุ่ม `บันทึกและปิดเอกสาร` — **เรนเดอร์เฉพาะ `closeSupported === true`** (Word on the web เรียก `close()` ไม่ได้ → ซ่อนทุกแบบ)
|
||||||
|
- สถานะตามลำดับ:
|
||||||
|
- `saveBusy && status === "saving"` → `Spinner "กำลังบันทึก…"`
|
||||||
|
- `!saveBusy && status === "saved"` → text จางๆ `บันทึกแล้ว` — ค้างไว้จนกดบันทึกรอบใหม่ (ไม่ reset เมื่อแก้ไขเอกสาร ไม่มี timeout)
|
||||||
|
- `saveError` → MessageBar **error**
|
||||||
|
- `autoSaveError` → MessageBar **warning** (default: "บันทึกอัตโนมัติไม่สำเร็จ — ลองกด \"บันทึก\" ด้วยตนเอง")
|
||||||
|
- ทั้งสองปุ่ม `disabled` เมื่อ `disabled` (flow versioning กำลังรัน) **หรือ** `saveBusy`
|
||||||
|
|
||||||
|
พฤติกรรมปุ่ม:
|
||||||
|
|
||||||
|
| ปุ่ม | Flow |
|
||||||
|
|---|---|
|
||||||
|
| `บันทึก` | save → สำเร็จตั้ง `saved`, พังโชว์ `saveError` |
|
||||||
|
| `บันทึกและปิดเอกสาร` | save → **สำเร็จเท่านั้นจึงปิดเอกสาร** · save พัง = ไม่ปิด + `saveError` (AC-21) · ปิดสำเร็จแล้ว task pane จะปิดตามเอกสารเอง — ไม่มี toast ท้าย flow |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Dialog ยืนยัน `ConfirmVersionDialog` (FR-2 + FR-13 + FR-18)
|
||||||
|
|
||||||
|
Dialog เดียวใช้ 2 flow (ปรับข้อความด้วย props):
|
||||||
|
|
||||||
|
| Prop | Save & Version | Start Versioning |
|
||||||
|
|---|---|---|
|
||||||
|
| title | "สร้าง version ใหม่?" | "เริ่มติดตามเอกสารนี้?" |
|
||||||
|
| description | "ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้" | ปกติ: "ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้" · ไฟล์ร่าง: "ระบบจะ stamp metadata ลงไฟล์ บันทึกไฟล์ปัจจุบันเป็น version 1 และผูกเอกสารกับ template ต้นทาง (ไฟล์ร่าง)" |
|
||||||
|
| ปุ่มยืนยันหลัก | "สร้าง version" | "เริ่มติดตาม (version 1)" |
|
||||||
|
|
||||||
|
ภายใน dialog:
|
||||||
|
|
||||||
|
- ปุ่ม `ยกเลิก` (secondary) มีเสมอ
|
||||||
|
- เมื่อมีปุ่มที่สอง ("บันทึกเวอร์ชันและปิดเอกสาร") → ปุ่มนั้นเป็น primary ตัวเดียว และปุ่มยืนยันหลัก ("สร้าง version"/"เริ่มติดตาม") กลายเป็น secondary
|
||||||
|
- ช่อง **"คำอธิบาย version (ไม่บังคับ)"** — textarea, `maxLength 2048`, 3 แถว, resize vertical, placeholder "เช่น แก้ไขข้อ 3 สัญญาจ้าง" — ค่าว่างได้ ส่งเป็น optional
|
||||||
|
- ปุ่มที่สอง **"บันทึกเวอร์ชันและปิดเอกสาร"** (primary) — เรนเดอร์เฉพาะเมื่อ `closeSupported` **และเฉพาะ flow Save & Version** (Start Versioning ไม่มี)
|
||||||
|
- ตอน `busy`: ทุกปุ่ม disable + spinner `กำลังดำเนินการ…` + ปิด dialog ไม่ได้ (ปุ่มยกเลิก/คลิกนอก/ESC ไม่ทำงาน) — สถานะนี้เป็น defense ชั่วขณะก่อน re-render เท่านั้น
|
||||||
|
- **กดปุ่มยืนยัน (ตัวใดก็ตาม) → dialog ปิดทันที** แล้ว progress/ผลลัพธ์ (spinner + progressMessage + flow messages) แสดงที่ pane — ไม่ปล่อย dialog ค้างบังผลลัพธ์ (กันกดยืนยันซ้ำสร้าง version ซ้ำ)
|
||||||
|
- focus: ตอนเปิดโฟกัสเข้าตัว dialog (container) + Tab ถูกกักไว้ใน dialog + ตอนปิดคืน focus ให้ปุ่ม trigger เดิม
|
||||||
|
- เคลียร์ช่อง message ทุกครั้งที่ dialog ปิด (component mount ค้างไว้ตลอด — ต้อง reset ตอน `open` เปลี่ยนเป็น false ไม่ใช่ตอน unmount)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `VersionHistoryPanel` (FR-4)
|
||||||
|
|
||||||
|
- Header: icon history + "Version history" (semibold)
|
||||||
|
- สถานะ: `loading` → `Spinner "กำลังโหลด…"` · `error` → MessageBar **error** + ปุ่ม `ลองใหม่` · ว่าง (ไม่ loading ไม่ error และไม่มี version) → text จางๆ "ยังไม่มี version ในระบบ"
|
||||||
|
- รายการ version เรียง **ใหม่ → เก่า** แต่ละแถว (hover มีพื้นหลัง):
|
||||||
|
- `Badge v{versionNumber}` (สี brand)
|
||||||
|
- timestamp รูปแบบ medium date + short time ตาม locale
|
||||||
|
- `โดย {createdByUserId}`
|
||||||
|
- commit message (ถ้ามี) — text 2 สี, `white-space: pre-wrap`, `overflow-wrap: anywhere`
|
||||||
|
- ปุ่มขวาแต่ละแถว (เรียงเป็นคอลัมน์):
|
||||||
|
- `ดาวน์โหลด` (icon download) → ตอนโหลด **label คงเดิม** สลับ icon เป็น spinner + `aria-busy` (label ยาว "กำลังดาวน์โหลด…" ทำแถว wrap กระโดด)
|
||||||
|
- `PDF` (icon pdf) — **แสดงเฉพาะ `hasPdf === true`** → ตอนโหลด label คงเดิม + spinner + `aria-busy`
|
||||||
|
- ระหว่างมี version ใดกำลังโหลด → ปุ่มโหลดของ**ทุกแถว** (ทั้ง `ดาวน์โหลด` และ `PDF`) disable กันกดซ้อน
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Flow หลักพร้อมข้อความ progress
|
||||||
|
|
||||||
|
ข้อความ flow (`flowSuccess`/`flowWarning`/`flowError`) ถูกเคลียร์เป็น null ทุกครั้งที่**เริ่ม** flow ใหม่ (Start Versioning / Save & Version / ดาวน์โหลด — ไม่ใช่ตอนจบ ไม่มี timeout)
|
||||||
|
|
||||||
|
### Detect (FR-1) — ตอนเปิด task pane / หลัง login / กดลองใหม่
|
||||||
|
|
||||||
|
1. ตรวจ token — ไม่มี → `signed-out`
|
||||||
|
2. อ่าน custom properties ครั้งเดียวทั้งชุด: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`, `TemplateId`
|
||||||
|
3. `tracked = (SavedByAddIn === GUID ของ add-in)`:
|
||||||
|
- tracked **และมี** `DocumentTrackingId` → `tracking` + โหลด history
|
||||||
|
- ไม่ track หรือ track แต่ id หาย → `untracked` (เก็บ `TemplateId` ไว้ใช้ตอน Start Versioning — ไฟล์ร่างอยู่กลุ่มนี้)
|
||||||
|
|
||||||
|
### Start Versioning (FR-1) — จาก `untracked`
|
||||||
|
|
||||||
|
dialog ยืนยัน → ข้อความ progress ตามลำดับ:
|
||||||
|
|
||||||
|
1. `กำลังบันทึก metadata ของเอกสาร…`
|
||||||
|
2. `กำลังอัปเดต version history ในไฟล์…`
|
||||||
|
3. `กำลังบันทึกไฟล์…`
|
||||||
|
4. `กำลังอ่านค่า fields จากเอกสาร…`
|
||||||
|
5. `กำลังอัปโหลด version 1…`
|
||||||
|
6. `กำลังสร้างสำเนา PDF…` (best-effort — ล้มได้ไม่พัง flow)
|
||||||
|
7. `กำลังอัปโหลด version…`
|
||||||
|
|
||||||
|
> ขั้น 5 กับ 7 ไม่ใช่การอัปโหลดสองครั้ง — ขั้น 5 เป็น marker เข้าสู่ช่วง upload ระหว่างนั้นขั้น 6 ค่อย capture PDF (best-effort) แล้วขั้น 7 คือ POST จริง**ครั้งเดียว**พร้อมแนบ `pdf` ใน multipart เดียวกัน (ตาม `uploadCurrentFile` ของ versionFlow.ts)
|
||||||
|
|
||||||
|
สำเร็จ → `tracking` + `flowSuccess` = "เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1" + refresh history · ถ้า `hasPdf === false` → `flowWarning` = "บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)" · พัง → `flowError` = "เริ่มติดตามเอกสารไม่สำเร็จ"
|
||||||
|
|
||||||
|
### Save & Version (FR-2) — จาก `tracking`
|
||||||
|
|
||||||
|
dialog ยืนยัน → progress ทั้ง 7 ขั้นลำดับเดียวกัน ต่างจาก Start Versioning แค่ **2 ข้อความ**: ขั้น 1 `กำลังบันทึก metadata ของ version ใหม่…` (เดิม "…metadata ของเอกสาร…") และขั้น 5 `กำลังอัปโหลด version ใหม่…` (เดิม "…version 1…") — ขั้น 2/3/4/6/7 ใช้ข้อความเดียวกันกับ Start Versioning เป๊ะ
|
||||||
|
|
||||||
|
หลัง upload สำเร็จ: ถ้าเลข version จริงจาก backend ต่างจากที่ client เดา (มี client อื่นแทรก) → แก้ XML part ในไฟล์ให้ตรง (best-effort ล้มได้เงียบๆ ไม่พัง flow)
|
||||||
|
|
||||||
|
สำเร็จ → `flowSuccess` = "สร้าง version {N} เรียบร้อย" + refresh history · `hasPdf === false` → warning เดียวกับด้านบน · กดปุ่มที่สอง (บันทึกเวอร์ชันและปิดเอกสาร) → version สำเร็จแล้วจึงปิดเอกสาร — ปิดสำเร็จ UI จบเลย (task pane ปิดตาม) · **ปิดไม่สำเร็จ** → แสดงเป็น **warning ไม่ใช่ error** ("บันทึก version N เรียบร้อย แต่ปิดเอกสารไม่สำเร็จ: … — ปิดเอกสารเองได้เลย") + refresh history ตาม เพราะ version ปลอดภัยบน backend แล้ว · พังก่อน upload → `flowError` = "สร้าง version ไม่สำเร็จ"
|
||||||
|
|
||||||
|
### Download version (FR-4/FR-7)
|
||||||
|
|
||||||
|
ปุ่มต่อแถวใน history → ขอ download-link → เปิดใน**เบราว์เซอร์ของเครื่อง** (ห้าม blob download ใน webview ของ Word — ถูกทิ้งเงียบๆ) → `flowSuccess` = "กำลังดาวน์โหลด v{N} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads" หรือ (pdf) "กำลังเปิด PDF ของ v{N} ในเบราว์เซอร์ของคุณ" · พัง → `flowError` = "ดาวน์โหลด version ไม่สำเร็จ"
|
||||||
|
|
||||||
|
### Auto-save (FR-17)
|
||||||
|
|
||||||
|
- เริ่มตั้งแต่ mount **ไม่รอ sign-in**
|
||||||
|
- periodic ทุก 30 วิ: ไฟล์ dirty → save + ตั้ง flag `hasUncommitted`; สะอาด → ข้าม
|
||||||
|
- save ล้ม → `autoSaveError` (warning bar); สำเร็จครั้งถัดไป → เคลียร์ bar
|
||||||
|
- ทุก flow versioning ต้อง **suspend** auto-save ตอนเริ่มและ **resume** ตอนจบ (กัน save แทรกกลาง stamp/capture)
|
||||||
|
- สร้าง version สำเร็จ → เคลียร์ `hasUncommitted`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API contract ที่ UI เรียก (จุด mock รอ API จริง)
|
||||||
|
|
||||||
|
Base URL มาจาก config (dev: `https://localhost:7232`) · ทุก request แนบ `Authorization: Bearer {token}` · backend serialize camelCase
|
||||||
|
|
||||||
|
### `POST /api/documents/{documentId}/versions` — อัปโหลด version
|
||||||
|
|
||||||
|
multipart/form-data:
|
||||||
|
|
||||||
|
| field | type | หมายเหตุ |
|
||||||
|
|---|---|---|
|
||||||
|
| `file` | Blob (.docx) | ชื่อไฟล์ดึงจาก URL ของ document ถ้าได้ (desktop มักเป็น path จริง — เติม `.docx` ถ้ายังไม่มี) · ดึงไม่ได้ = ไม่ส่งชื่อ ให้ backend ตั้งเอง |
|
||||||
|
| `clientVersionId` | string (GUID) | client generate เองก่อน upload |
|
||||||
|
| `pdf` | Blob | optional — แนบเมื่อ capture PDF สำเร็จ |
|
||||||
|
| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` จาก content controls |
|
||||||
|
| `message` | string | optional — commit message (trim แล้วว่าง = ไม่ส่ง, ≤2048) |
|
||||||
|
| `templateId` | string (GUID) | optional — เฉพาะ Start Versioning ของไฟล์ร่าง (FR-16) |
|
||||||
|
|
||||||
|
response: `{ hasPdf: boolean, versionId: string, versionNumber: number }`
|
||||||
|
|
||||||
|
### `GET /api/documents/{documentId}/versions` — history
|
||||||
|
|
||||||
|
response: `VersionRecord[]` เรียงใหม่ → เก่า:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface VersionRecord {
|
||||||
|
versionId: string;
|
||||||
|
versionNumber: number;
|
||||||
|
createdAt: string; // ISO-8601
|
||||||
|
createdByUserId: string;
|
||||||
|
contentHash: string; // SHA256 hex
|
||||||
|
message: string | null; // commit message (FR-13)
|
||||||
|
hasPdf?: boolean;
|
||||||
|
fields: FieldsSnapshot | null;
|
||||||
|
}
|
||||||
|
// fields เก็บ 2 รูปแบบ: เอกสารผูก template → { [key]: { value, missing } }
|
||||||
|
// ไม่ผูก template → { [tag]: text } ดิบๆ — UI ต้องทนทั้งคู่
|
||||||
|
// (UI ปัจจุบันไม่ได้แสดง fields — type + helper readSnapshotField เตรียมไว้ใช้ภายหลัง)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/documents/{documentId}/versions/{versionId}/download-link[?format=pdf|docx]` — ขอ URL อายุสั้น
|
||||||
|
|
||||||
|
response: `{ url: string, expiresAt: string }` — UI เปิด `url` ในเบราว์เซอร์เครื่องผู้ใช้เท่านั้น
|
||||||
|
|
||||||
|
### Auth — OIDC PKCE ผ่าน Office Dialog API
|
||||||
|
|
||||||
|
`displayDialogAsync` เปิดหน้า login แยก → `messageParent` ส่งผลกลับ task pane · token เก็บ **ใน memory เท่านั้น** (ห้าม localStorage) · refresh token auto-refresh เงียบๆ จน 401 → สถานะ `AuthRequiredError` · mock ช่วงรอ: `login()` คืน `{ ok: true }`, `getAccessToken()` คืน token ปลอม, `getUsername()` คืนชื่อ test
|
||||||
|
|
||||||
|
> **หมายเหตุสถานะปัจจุบัน (2026-10-14):** UI นี้ถูก implement แล้วใน repo นี้ที่ route `/docedit/wordaddin` (ดู `components/wordaddin/` + `wordaddin/`) — ทั้ง auth (แทนด้วย mock `wordaddin/services/authService.ts`) และ API ทั้งสามข้างบน (แทนด้วย in-memory mock `wordaddin/services/versioningService.ts`) · วิธีสลับกลับของจริง: `docs/WORDADDIN-DEV.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. กติกา error ที่ UI ต้องรองรับ
|
||||||
|
|
||||||
|
1. **`AuthRequiredError` (401 ที่ refresh ไม่ช่วย)** — ทุก flow ต้อง catch แล้ว: กลับ `signed-out` + เคลียร์ `documentId`/`documentTemplateId`/`versions` + ตั้ง `loginError` = "เซสชันหมดอายุ — กรุณา login ใหม่" — **ไม่ throw ต่อ ไม่โชว์ error ทับ phase**
|
||||||
|
2. error อื่น → MessageBar ณ จุดที่เกิด (login/history/flow/download แยกกัน) — ห้ามปล่อย unhandled rejection
|
||||||
|
3. แยก error ของ "ตัว version" ออกจาก "ปิดเอกสารไม่สำเร็จ" — อันหลังเป็น warning เพราะข้อมูลปลอดภัยแล้ว
|
||||||
|
4. PDF เป็น best-effort เสมอ — ล้มแค่เตือน ไม่ fail flow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. ข้อควรระวังที่กระทบหน้าตา UI
|
||||||
|
|
||||||
|
1. `closeSupported` เป็น flag เดียวที่คุมปุ่ม "บันทึกและปิดเอกสาร" (SaveControls) **และ** ปุ่มที่สองใน ConfirmVersionDialog — Word on the web = false
|
||||||
|
2. disable กันกดซ้อน: ปุ่ม flow หนึ่งรัน → ปุ่ม flow อื่น + SaveControls disable ด้วย (แต่ `saveBusy` กับ `saving` แยกกัน — จะไม่ปิดกันเองระหว่าง local save)
|
||||||
|
3. ของเดิมใช้ Fluent UI v9 (`@fluentui/react-components` + `@fluentui/react-icons`) — mapping ไป library อื่น: Button / Dialog / MessageBar / Spinner / Badge / Textarea / Text
|
||||||
|
4. UI ทั้งหมดภาษาไทย — ข้อความสำเนาได้จากไฟล์ต้นฉบับที่อ้างด้านบน
|
||||||
|
5. Task pane แคบ (side panel) — layout เป็นคอลัมน์เดียว ปุ่ม/actions ต้องรองรับความกว้างจำกัด
|
||||||
|
|
@ -24,12 +24,17 @@ export function middleware(req: NextRequest) {
|
||||||
// ยกเว้น: หน้า login เอง, API/SSE/export (อยู่ใต้ api/note/**), API template
|
// ยกเว้น: หน้า login เอง, API/SSE/export (อยู่ใต้ api/note/**), API template
|
||||||
// (api/template/** — เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย ไม่แตะ DB
|
// (api/template/** — เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย ไม่แตะ DB
|
||||||
// ไม่มีข้อมูลรับ), หน้า Swagger UI (api/swagger — document ของทุกกลุ่ม),
|
// ไม่มีข้อมูลรับ), หน้า Swagger UI (api/swagger — document ของทุกกลุ่ม),
|
||||||
// ไฟล์อัปโหลด, Next internal, และ static ใน public/note/ (pdf.worker, samples,
|
// taskpane ของ wordaddin (wordaddin/** — รันใน webview ของ Word ไม่มี cookie
|
||||||
// asset Swagger UI) + public/ ราก (fonts, favicon)
|
// oacCase และมี auth ของตัวเองแยกจากแอป note), ไฟล์อัปโหลด, Next internal,
|
||||||
|
// และ static ใน public/note/ (pdf.worker, samples, asset Swagger UI)
|
||||||
|
// + public/ ราก (fonts, favicon)
|
||||||
// — matcher ทำงานบน path หลังตัด basePath (/docedit): หน้า/ไฟล์ของแอปขึ้นต้น note/
|
// — matcher ทำงานบน path หลังตัด basePath (/docedit): หน้า/ไฟล์ของแอปขึ้นต้น note/
|
||||||
// ส่วน API ขึ้นต้น api/note/ (URL /docedit/api/note/…)
|
// ส่วน API ขึ้นต้น api/note/ (URL /docedit/api/note/…)
|
||||||
|
// — root "/" ไม่ถูก match (ใช้ .+ ไม่ใช่ .*) เพราะเป็น redirect สู่ taskpane
|
||||||
|
// wordaddin ซึ่ง public (คำขอผู้ใช้ 2026-10-14) — ลิงก์ e-Court N8 ที่ root
|
||||||
|
// ยังถูกคุมอยู่ดีเพราะ root page ส่งต่อให้ /note ซึ่ง middleware จับตามปกติ
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
"/((?!note/login|api/note|api/template|api/swagger|note/uploads|note/samples|note/swagger-ui|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).*)",
|
"/((?!note/login|api/note|api/template|api/swagger|wordaddin|note/uploads|note/samples|note/swagger-ui|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).+)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
8096
package-lock.json
generated
8096
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -8,7 +8,9 @@
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"seed-samples": "node --env-file=.env scripts/seed-samples.mjs",
|
"seed-samples": "node --env-file=.env scripts/seed-samples.mjs",
|
||||||
"postinstall": "node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs"
|
"postinstall": "node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs && node scripts/fetch-office-js.mjs",
|
||||||
|
"dev:https": "next dev --experimental-https",
|
||||||
|
"dev:https:cert": "next dev --experimental-https --experimental-https-key certs/localhost.key --experimental-https-cert certs/localhost.crt"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pdf-lib/fontkit": "^1.1.1",
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
|
|
@ -29,12 +31,15 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^26.5.0",
|
"@types/node": "^26.5.0",
|
||||||
|
"@types/office-js": "^1.0.612",
|
||||||
"@types/pngjs": "^6.0.5",
|
"@types/pngjs": "^6.0.5",
|
||||||
"@types/react": "^18.3.31",
|
"@types/react": "^18.3.31",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"autoprefixer": "^10.5.5",
|
"autoprefixer": "^10.5.5",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"office-addin-debugging": "^7.0.1",
|
||||||
|
"office-addin-dev-settings": "^4.0.1",
|
||||||
"postcss": "^8.5.28",
|
"postcss": "^8.5.28",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
|
|
||||||
7418
pnpm-lock.yaml
generated
Normal file
7418
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
BIN
public/wordaddin/icons/icon-16.png
Normal file
BIN
public/wordaddin/icons/icon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/wordaddin/icons/icon-32.png
Normal file
BIN
public/wordaddin/icons/icon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/wordaddin/icons/icon-64.png
Normal file
BIN
public/wordaddin/icons/icon-64.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/wordaddin/icons/icon-80.png
Normal file
BIN
public/wordaddin/icons/icon-80.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
109
public/wordaddin/manifest.xml
Normal file
109
public/wordaddin/manifest.xml
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<!--
|
||||||
|
manifest ของ Word add-in "e-Court Documents Versioning" (พอร์ตจาก D:\word-addin-poc\addin\manifest.xml)
|
||||||
|
|
||||||
|
⚠️ URL ทุกตัวเป็น absolute https ตามข้อบังคับของ Office — ต้อง rewrite ต่อ environment:
|
||||||
|
dev (default ด้านล่าง) = https://localhost:3000/docedit/wordaddin
|
||||||
|
deploy จริง = https://<โฮสต์ intranet>/docedit/wordaddin
|
||||||
|
(รายละเอียดวิธีรัน dev + sideload: docs/WORDADDIN-DEV.md)
|
||||||
|
|
||||||
|
ผลต่างจากต้นฉบับ:
|
||||||
|
- taskpane ชี้ที่หน้า Next.js (/docedit/wordaddin) แทน taskpane.html ของ webpack
|
||||||
|
- ตัด DialogAPI (login ช่วงนี้เป็น mock ไม่มี Keycloak dialog) — เหลือ WordApi 1.5 + OpenBrowserWindowApi
|
||||||
|
- ตัด AppDomain ของ Keycloak (https://localhost:8443)
|
||||||
|
- Commands.Url ชี้ taskpane URL เดียวกัน (ไม่มี commands.html — ปุ่มเดียวคือ ShowTaskpane)
|
||||||
|
- คง Id GUID เดิม — เครื่องที่เคย sideload ตัว poc จะถือว่าเป็น add-in ตัวเดิม
|
||||||
|
-->
|
||||||
|
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:ov="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="TaskPaneApp">
|
||||||
|
<Id>4a1e184a-ab31-4fa4-b3c7-9a2283051c3f</Id>
|
||||||
|
<Version>1.0.0.1</Version>
|
||||||
|
<ProviderName>e-Court Documents Versioning</ProviderName>
|
||||||
|
<DefaultLocale>en-US</DefaultLocale>
|
||||||
|
<DisplayName DefaultValue="e-Court Documents Versioning"/>
|
||||||
|
<Description DefaultValue="Opens the e-Court Documents Versioning task pane."/>
|
||||||
|
<IconUrl DefaultValue="https://localhost:3000/docedit/wordaddin/icons/icon-32.png"/>
|
||||||
|
<HighResolutionIconUrl DefaultValue="https://localhost:3000/docedit/wordaddin/icons/icon-64.png"/>
|
||||||
|
<SupportUrl DefaultValue="https://localhost:3000/docedit/wordaddin"/>
|
||||||
|
<AppDomains>
|
||||||
|
<AppDomain>https://localhost:3000</AppDomain>
|
||||||
|
</AppDomains>
|
||||||
|
<Hosts>
|
||||||
|
<Host Name="Document"/>
|
||||||
|
</Hosts>
|
||||||
|
<!-- Requirement sets ที่ services layer ใช้จริง — host ที่ไม่รองรับจะไม่โหลด add-in เลย (fail fast ดีกว่าพังกลางทาง)
|
||||||
|
WordApi 1.5: customProperties / customXmlParts / document.save()
|
||||||
|
OpenBrowserWindowApi 1.1: เปิด presigned download URL ในเบราว์เซอร์ของเครื่อง (webview ของ Word ทิ้ง blob download) -->
|
||||||
|
<Requirements>
|
||||||
|
<Sets DefaultMinVersion="1.1">
|
||||||
|
<Set Name="WordApi" MinVersion="1.5"/>
|
||||||
|
<Set Name="OpenBrowserWindowApi" MinVersion="1.1"/>
|
||||||
|
</Sets>
|
||||||
|
</Requirements>
|
||||||
|
<DefaultSettings>
|
||||||
|
<SourceLocation DefaultValue="https://localhost:3000/docedit/wordaddin"/>
|
||||||
|
</DefaultSettings>
|
||||||
|
<Permissions>ReadWriteDocument</Permissions>
|
||||||
|
<VersionOverrides xmlns="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="VersionOverridesV1_0">
|
||||||
|
<Hosts>
|
||||||
|
<Host xsi:type="Document">
|
||||||
|
<DesktopFormFactor>
|
||||||
|
<GetStarted>
|
||||||
|
<Title resid="GetStarted.Title"/>
|
||||||
|
<Description resid="GetStarted.Description"/>
|
||||||
|
<LearnMoreUrl resid="GetStarted.LearnMoreUrl"/>
|
||||||
|
</GetStarted>
|
||||||
|
<FunctionFile resid="Commands.Url"/>
|
||||||
|
<ExtensionPoint xsi:type="PrimaryCommandSurface">
|
||||||
|
<OfficeTab id="TabHome">
|
||||||
|
<Group id="CommandsGroup">
|
||||||
|
<Label resid="CommandsGroup.Label"/>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Icon.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Icon.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Icon.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Control xsi:type="Button" id="TaskpaneButton">
|
||||||
|
<Label resid="TaskpaneButton.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="TaskpaneButton.Label"/>
|
||||||
|
<Description resid="TaskpaneButton.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Icon.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Icon.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Icon.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ShowTaskpane">
|
||||||
|
<TaskpaneId>ButtonId1</TaskpaneId>
|
||||||
|
<SourceLocation resid="Taskpane.Url"/>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
</Group>
|
||||||
|
</OfficeTab>
|
||||||
|
</ExtensionPoint>
|
||||||
|
</DesktopFormFactor>
|
||||||
|
</Host>
|
||||||
|
</Hosts>
|
||||||
|
<Resources>
|
||||||
|
<bt:Images>
|
||||||
|
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/docedit/wordaddin/icons/icon-16.png"/>
|
||||||
|
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/docedit/wordaddin/icons/icon-32.png"/>
|
||||||
|
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/docedit/wordaddin/icons/icon-80.png"/>
|
||||||
|
</bt:Images>
|
||||||
|
<bt:Urls>
|
||||||
|
<bt:Url id="GetStarted.LearnMoreUrl" DefaultValue="https://go.microsoft.com/fwlink/?LinkId=276812"/>
|
||||||
|
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/docedit/wordaddin"/>
|
||||||
|
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/docedit/wordaddin"/>
|
||||||
|
</bt:Urls>
|
||||||
|
<bt:ShortStrings>
|
||||||
|
<bt:String id="GetStarted.Title" DefaultValue="Get started with your add-in!"/>
|
||||||
|
<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 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="Opens the e-Court Documents Versioning task pane."/>
|
||||||
|
</bt:LongStrings>
|
||||||
|
</Resources>
|
||||||
|
</VersionOverrides>
|
||||||
|
</OfficeApp>
|
||||||
153
scripts/fetch-office-js.mjs
Normal file
153
scripts/fetch-office-js.mjs
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
// ดาวน์โหลด Office.js runtime "ฉบับล่าสุด" จาก CDN อย่างเป็นทางการ
|
||||||
|
// (appsforoffice.microsoft.com/lib/1/hosted) มา self-host ที่ public/wordaddin/office-js/
|
||||||
|
// ให้ taskpane ของ wordaddin โหลดแบบ same-origin ได้ (/docedit/wordaddin/office-js/office.js)
|
||||||
|
// — ระบบ deploy ใช้ใน intranet จึง self-host (CDN ใช้ไม่ได้ในเครือข่ายศาล) ส่วนตัว
|
||||||
|
// ดาวน์โหลดเองตอน npm/pnpm install บนเครื่องที่มีอินเทอร์เน็ต เหมือนดึง dependency อื่น ๆ
|
||||||
|
//
|
||||||
|
// ทำไมไม่ใช้แพ็กเกจ @microsoft/office-js แบบเดิม: npm หยุดอัปเดตที่ 1.1.110 (~2018)
|
||||||
|
// ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` ที่ FR-18 ต้องใช้ (เรียกแล้ว
|
||||||
|
// ได้ "context.document.close is not a function") และไม่มี enum Word.CloseBehavior /
|
||||||
|
// Word.SaveBehavior ทับ ๆ กับ @types/office-js ฉบับใหม่ — จึงต้องเป็นฉบับ CDN ล่าสุดที่ทัน
|
||||||
|
// API ปัจจุบัน (rolling — ได้ตัวใหม่ทุกครั้งที่ติดตั้ง และตรงกับ @types ที่ compile ด้วย)
|
||||||
|
//
|
||||||
|
// ชุดไฟล์ = **เฉพาะของ Word** ตามที่ loader อ้างถึง: ไฟล์ host ราย platform จากตาราง
|
||||||
|
// เวอร์ชันใน office.js (word: {ios:16.00, mac:16.00, web:16.00, win32:16.01}) + ไฟล์
|
||||||
|
// legacy 15.x กัน Word รุ่นเก่าในองค์กร + o15apptofilemappingtable/microsoftajax ที่
|
||||||
|
// loader ดึง + office_strings.js ต่อ locale — ไฟล์หลักหายตัวไหน (CDN เปลี่ยนชื่อ/ลบ) =
|
||||||
|
// exit 1 ดัง ๆ ทันที ไม่ปล่อยให้ taskpane พังเงียบ ๆ (pattern เดียวกับ copy-swagger-ui.mjs)
|
||||||
|
// ยกเว้น office_strings.js ของ locale รอง — หายเป็น warning (loader fallback เป็น en-us)
|
||||||
|
//
|
||||||
|
// หลังดาวน์โหลดตรวจ API surface ของ word-win32-16.01.js อีกชั้น: ต้องมี "Document.close"
|
||||||
|
// และ CloseBehavior — วันหนึ่งถ้า CDN ถอด API ที่ add-in ใช้ ให้ติดตั้งพังตั้งแต่ต้น
|
||||||
|
// ไม่ใช่มาพังตอนผู้ใช้กดปุ่มใน Word
|
||||||
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
const CDN = "https://appsforoffice.microsoft.com/lib/1/hosted";
|
||||||
|
const DEST = "public/wordaddin/office-js";
|
||||||
|
|
||||||
|
/** ไฟล์หลักที่ loader ดึงตอนรันจริง — หายตัวไหน = พังทั้ง taskpane */
|
||||||
|
const REQUIRED_FILES = [
|
||||||
|
"office.js",
|
||||||
|
"o15apptofilemappingtable.js",
|
||||||
|
"microsoftajax.js",
|
||||||
|
// host files ราย platform (ตารางเวอร์ชันของ loader) + legacy 15.x
|
||||||
|
"word-win32-16.01.js",
|
||||||
|
"word-web-16.00.js",
|
||||||
|
"word-mac-16.00.js",
|
||||||
|
"word-ios-16.00.js",
|
||||||
|
"word-15.js",
|
||||||
|
"word-15.01.js",
|
||||||
|
"word-15.02.js",
|
||||||
|
"wordios-15.js",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** locale ที่หายไม่ได้ (ผู้ใช้หลักของระบบ) — locale อื่นหายเป็น warning */
|
||||||
|
const REQUIRED_LOCALES = ["en-us", "th-th"];
|
||||||
|
|
||||||
|
/** locale ที่ CDN มี office_strings.js จริง (snapshot 2026-10 — Microsoft รวม locale รอง
|
||||||
|
* ให้ fallback ไป en-us แล้ว เหลือ ~64 ภาษาหลัก) — locale อื่น ๆ ของผู้ใช้จะได้ข้อความ
|
||||||
|
* อังกฤษ ไม่ใช่ taskpane พัง */
|
||||||
|
const LOCALES = [
|
||||||
|
"am-et", "ar-sa", "bg-bg", "bn-in", "ca-es", "cs-cz", "cy-gb", "da-dk", "de-de", "el-gr",
|
||||||
|
"en-us", "es-es", "es-mx", "et-ee", "eu-es", "fa-ir", "fi-fi", "fil-ph", "fr-ca", "fr-fr",
|
||||||
|
"gl-es", "gu-in", "he-il", "hi-in", "hr-hr", "hu-hu", "id-id", "is-is", "it-it", "ja-jp",
|
||||||
|
"kk-kz", "kn-in", "ko-kr", "lo-la", "lt-lt", "lv-lv", "ml-in", "mr-in", "ms-my", "nb-no",
|
||||||
|
"nl-nl", "nn-no", "pl-pl", "pt-br", "pt-pt", "ro-ro", "ru-ru", "sk-sk", "sl-si", "sr-cyrl-cs",
|
||||||
|
"sr-cyrl-rs", "sr-latn-cs", "sr-latn-rs", "sv-se", "sw-ke", "ta-in", "te-in", "th-th", "tr-tr",
|
||||||
|
"uk-ua", "ur-pk", "vi-vn", "zh-cn", "zh-tw",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** ดึงไฟล์จาก CDN แบบ retry — คืน null เมื่อล้มทุกรอบ (เรียกต่อไปตัดสิน fatal/warning เอง) */
|
||||||
|
async function fetchText(path, retries = 2) {
|
||||||
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${CDN}/${path}`, { redirect: "follow" });
|
||||||
|
if (response.ok) {
|
||||||
|
return { text: await response.text(), lastModified: response.headers.get("last-modified") };
|
||||||
|
}
|
||||||
|
if (response.status === 404) return null; // ไม่มีจริง — retry ไม่ช่วย
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === retries) {
|
||||||
|
console.error(`[fetch-office-js] ดึง ${path} ไม่สำเร็จหลัง retry: ${error?.message ?? error}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(path, text) {
|
||||||
|
const target = join(DEST, path);
|
||||||
|
mkdirSync(dirname(target), { recursive: true });
|
||||||
|
writeFileSync(target, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ลบโฟลเดอร์เดิมเป็น best-effort — บน Windows ตอน next dev เปิดค้าง chokidar อาจจับ
|
||||||
|
// handle โฟลเดอร์ public/ ไว้ (EPERM แม่น ๆ) ลบไม่ได้ก็เขียนทับในที่เดิมได้ (ไฟล์ที่ CDN
|
||||||
|
// เลิกใช้จะตกค้างเงียบ ๆ ใน dev แต่ fresh install/Docker ได้โฟลเดอร์สะอาดตามปกติ)
|
||||||
|
try {
|
||||||
|
rmSync(DEST, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 });
|
||||||
|
} catch {
|
||||||
|
console.warn("[fetch-office-js] ลบโฟลเดอร์เดิมไม่ได้ (process อื่นจับ handle อยู่ เช่น dev server) — เขียนทับในที่เดิม");
|
||||||
|
}
|
||||||
|
mkdirSync(DEST, { recursive: true });
|
||||||
|
|
||||||
|
// --- ไฟล์หลัก: ทีละตัว (ไฟล์ใหญ่) ตัวไหนหาย = fatal ---
|
||||||
|
let officeLastModified;
|
||||||
|
for (const file of REQUIRED_FILES) {
|
||||||
|
const result = await fetchText(file);
|
||||||
|
if (!result) {
|
||||||
|
console.error(
|
||||||
|
`[fetch-office-js] ไม่ได้ไฟล์หลัก ${file} — CDN เปลี่ยนชื่อ/ลบไฟล์รึเปล่า ` +
|
||||||
|
`(ปรับ REQUIRED_FILES ใน scripts/fetch-office-js.mjs) หรือเครือข่ายสู่ appsforoffice.microsoft.com ไม่ผ่าน`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
save(file, result.text);
|
||||||
|
if (file === "office.js") officeLastModified = result.lastModified;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- locale strings: ยิงกลุ่มละ 8 ไฟล์ (ไฟล์เล็ก) — รองหาย = warning ---
|
||||||
|
const missingLocales = [];
|
||||||
|
for (let i = 0; i < LOCALES.length; i += 8) {
|
||||||
|
const batch = LOCALES.slice(i, i + 8);
|
||||||
|
await Promise.all(
|
||||||
|
batch.map(async (locale) => {
|
||||||
|
const result = await fetchText(`${locale}/office_strings.js`);
|
||||||
|
if (result) {
|
||||||
|
save(`${locale}/office_strings.js`, result.text);
|
||||||
|
} else {
|
||||||
|
missingLocales.push(locale);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (missingLocales.length > 0) {
|
||||||
|
console.warn(`[fetch-office-js] office_strings.js หาย ${missingLocales.length} locale (loader จะ fallback en-us): ${missingLocales.join(", ")}`);
|
||||||
|
}
|
||||||
|
const missingRequiredLocales = REQUIRED_LOCALES.filter((locale) => missingLocales.includes(locale));
|
||||||
|
if (missingRequiredLocales.length > 0) {
|
||||||
|
console.error(`[fetch-office-js] หาย office_strings.js ของ locale หลัก: ${missingRequiredLocales.join(", ")}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ตรวจ API surface ที่ add-in ใช้จริง (กัน CDN regression หลุดเงียบ) ---
|
||||||
|
const win32 = readFileSync(join(DEST, "word-win32-16.01.js"), "utf8");
|
||||||
|
const missingApis = [];
|
||||||
|
if (!win32.includes('"Document.close"')) missingApis.push('"Document.close" (FR-18 ปิดเอกสาร)');
|
||||||
|
if (!win32.includes("CloseBehavior")) missingApis.push("CloseBehavior enum");
|
||||||
|
if (missingApis.length > 0) {
|
||||||
|
console.error(
|
||||||
|
`[fetch-office-js] word-win32-16.01.js จาก CDN ไม่มี API: ${missingApis.join(", ")} — ` +
|
||||||
|
`อย่า deploy ตัวนี้ (ปุ่มปิดเอกสารจะพังใน Word) แจ้งทีมแล้วตรวจ scripts/fetch-office-js.mjs`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[fetch-office-js] ดาวน์โหลด Office.js runtime ล่าสุดเรียบร้อย ` +
|
||||||
|
`(office.js ของวันที่ ${officeLastModified ?? "ไม่ทราบ"} — ${REQUIRED_FILES.length} ไฟล์หลัก + ${LOCALES.length - missingLocales.length} locale)`,
|
||||||
|
);
|
||||||
|
|
@ -4,11 +4,13 @@
|
||||||
// basePath ให้อัตโนมัติ (path ที่เขียนถึง route จึงต้องมี /note นำหน้าเอง เช่น "/note/login")
|
// basePath ให้อัตโนมัติ (path ที่เขียนถึง route จึงต้องมี /note นำหน้าเอง เช่น "/note/login")
|
||||||
// ยกเว้น API route อยู่ใต้ app/api/note/** (URL /docedit/api/note/…) — path ที่ขึ้นต้น
|
// ยกเว้น API route อยู่ใต้ app/api/note/** (URL /docedit/api/note/…) — path ที่ขึ้นต้น
|
||||||
// "/api/" เติมแค่ basePath (caller เขียน "/api/note/…" เอง) ไม่มี /note ซ้อน
|
// "/api/" เติมแค่ basePath (caller เขียน "/api/note/…" เอง) ไม่มี /note ซ้อน
|
||||||
|
// และ static ของ sibling wordaddin ใต้ public/wordaddin/** (URL /docedit/wordaddin/…
|
||||||
|
// เช่น office.js ที่ self-host) — path ที่ขึ้นต้น "/wordaddin/" เติมแค่ basePath เช่นกัน
|
||||||
// (pdfUrl ใน DB เก็บ logical path ไม่รวม prefix เติมตอนใช้เท่านั้น)
|
// (pdfUrl ใน DB เก็บ logical path ไม่รวม prefix เติมตอนใช้เท่านั้น)
|
||||||
export function withBasePath(path: string): string {
|
export function withBasePath(path: string): string {
|
||||||
// absolute http(s) ผ่านไปเฉย ๆ (N8: pdf_url เก็บ URL รีโมตได้ — ห้าม prefix ทับ)
|
// absolute http(s) ผ่านไปเฉย ๆ (N8: pdf_url เก็บ URL รีโมตได้ — ห้าม prefix ทับ)
|
||||||
if (/^https?:\/\//i.test(path)) return path;
|
if (/^https?:\/\//i.test(path)) return path;
|
||||||
if (path.startsWith("/api/")) {
|
if (path.startsWith("/api/") || path.startsWith("/wordaddin/")) {
|
||||||
return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${path}`;
|
return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${path}`;
|
||||||
}
|
}
|
||||||
return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${process.env.NEXT_PUBLIC_APP_PATH ?? ""}${path}`;
|
return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${process.env.NEXT_PUBLIC_APP_PATH ?? ""}${path}`;
|
||||||
|
|
|
||||||
13
wordaddin/config.ts
Normal file
13
wordaddin/config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* Config ของ wordaddin — ค่าจาก NEXT_PUBLIC_* env (Next inline ตอน build)
|
||||||
|
* (ต้นฉบับ D:\word-addin-poc\addin inject ผ่าน webpack DefinePlugin — พอร์ตมาเป็น env ของ Next)
|
||||||
|
*
|
||||||
|
* ช่วง mock: authService/versioningService เป็น mock ไม่ใช้ apiBaseUrl —
|
||||||
|
* ตั้งค่านี้เมื่อปลด mock ต่อ backend จริง (ต้องเป็น HTTPS เสมอ — webview ของ Word บล็อก http)
|
||||||
|
*/
|
||||||
|
export const config = {
|
||||||
|
/** base URL ของ backend API (ไม่มี slash ท้าย) — ว่าง = ยังไม่ต่อ backend จริง */
|
||||||
|
apiBaseUrl: process.env.NEXT_PUBLIC_ADDIN_API_BASE_URL ?? "",
|
||||||
|
/** ชื่อผู้ใช้ของ mock auth — แสดงหน้า header + เป็น createdByUserId ของ version ที่ mock สร้าง */
|
||||||
|
mockUsername: process.env.NEXT_PUBLIC_ADDIN_MOCK_USERNAME ?? "tester01",
|
||||||
|
};
|
||||||
58
wordaddin/models/versionRecord.ts
Normal file
58
wordaddin/models/versionRecord.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
/**
|
||||||
|
* Type ตรงกับ response ของ backend (`Models/VersionDtos.cs` — ASP.NET Core serialize camelCase)
|
||||||
|
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\models\versionRecord.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** response ของ POST /api/documents/{documentId}/versions */
|
||||||
|
export interface UploadVersionResult {
|
||||||
|
hasPdf: boolean;
|
||||||
|
versionId: string;
|
||||||
|
versionNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** field เดียวใน snapshot ของ version (เอกสารที่ผูก template) */
|
||||||
|
export interface FieldSnapshotEntry {
|
||||||
|
value: string;
|
||||||
|
missing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* snapshot fields ต่อ version — backend เก็บ 2 รูปแบบ:
|
||||||
|
* - เอกสารผูก template: `{ [fieldKey]: { value, missing } }`
|
||||||
|
* - เอกสารไม่ผูก template (track เองจาก add-in): map ดิบ `{ [tag]: text }`
|
||||||
|
* UI ต้อง handle ได้ทั้งคู่ (ใช้ readSnapshotField ช่วยอ่าน)
|
||||||
|
*/
|
||||||
|
export type FieldsSnapshot = Record<string, FieldSnapshotEntry> | Record<string, string>;
|
||||||
|
|
||||||
|
/** item ของ GET /api/documents/{documentId}/versions */
|
||||||
|
export interface VersionRecord {
|
||||||
|
hasPdf?: boolean;
|
||||||
|
versionId: string;
|
||||||
|
versionNumber: number;
|
||||||
|
/** ISO-8601 */
|
||||||
|
createdAt: string;
|
||||||
|
createdByUserId: string;
|
||||||
|
/** SHA256 hex */
|
||||||
|
contentHash: string;
|
||||||
|
fields: FieldsSnapshot | null;
|
||||||
|
/** คำอธิบาย version แบบ commit message ที่ผู้ใช้พิมพ์ตอนสร้าง (FR-13) — null ได้ */
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** อ่านค่า + สถานะ missing ของ field หนึ่งจาก snapshot ไม่ว่าจะเก็บรูปแบบไหน — ไม่มี field นี้คืน null */
|
||||||
|
export function readSnapshotField(
|
||||||
|
snapshot: FieldsSnapshot | null,
|
||||||
|
key: string
|
||||||
|
): FieldSnapshotEntry | null {
|
||||||
|
if (!snapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const raw = snapshot[key];
|
||||||
|
if (raw === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
return { value: raw, missing: false };
|
||||||
|
}
|
||||||
|
return { value: raw.value, missing: raw.missing };
|
||||||
|
}
|
||||||
122
wordaddin/services/apiClient.ts
Normal file
122
wordaddin/services/apiClient.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { config } from "../config";
|
||||||
|
import * as authService from "./authService";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetch wrapper ของ add-in — แนบ Bearer ทุก request
|
||||||
|
* โดน 401 → silent refresh แล้ว retry ครั้งเดียว; ยัง 401 → AuthRequiredError
|
||||||
|
* (UI จับ error นี้แล้วพากลับหน้า sign-in อย่างเนียน — AC-9)
|
||||||
|
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\apiClient.ts
|
||||||
|
* (ช่วง mock ไม่มี caller จริง — เตรียมไว้ให้ versioningService วันปลด mock + AuthRequiredError
|
||||||
|
* ถูก TaskpaneApp ใช้อยู่แล้ว)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** บอกว่าต้องกลับไป login ใหม่ (ไม่มี token / refresh ไม่ได้ / 401 ซ้ำ) */
|
||||||
|
export class AuthRequiredError extends Error {
|
||||||
|
constructor(message = "เซสชันหมดอายุ — ต้อง login ใหม่") {
|
||||||
|
super(message);
|
||||||
|
this.name = "AuthRequiredError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** backend ตอบมาแต่ status ไม่สำเร็จ — message อ่านได้จาก body ถ้ามี */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiRequestOptions {
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: BodyInit | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUrl(path: string): string {
|
||||||
|
return path.startsWith("http") ? path : `${config.apiBaseUrl}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doFetch(
|
||||||
|
path: string,
|
||||||
|
options: ApiRequestOptions,
|
||||||
|
accessToken: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const headers = new Headers(options.headers ?? {});
|
||||||
|
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||||
|
return fetch(toUrl(path), {
|
||||||
|
method: options.method ?? "GET",
|
||||||
|
headers,
|
||||||
|
body: options.body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* request พร้อม Bearer + retry-on-401 ครั้งเดียว
|
||||||
|
* Response ที่ได้คืนไปให้ caller เช็ค ok เอง (ยกเว้น 401 ที่ refresh ไม่ช่วย → AuthRequiredError)
|
||||||
|
*/
|
||||||
|
export async function apiFetch(path: string, options: ApiRequestOptions = {}): Promise<Response> {
|
||||||
|
const accessToken = await authService.getAccessToken();
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new AuthRequiredError();
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = await doFetch(path, options, accessToken);
|
||||||
|
if (response.status === 401) {
|
||||||
|
const refreshedToken = await authService.refreshAccessToken();
|
||||||
|
if (!refreshedToken) {
|
||||||
|
throw new AuthRequiredError();
|
||||||
|
}
|
||||||
|
response = await doFetch(path, options, refreshedToken);
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new AuthRequiredError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** อ่าน error message จาก response body — backend error ส่วนใหญ่เป็น plain text, บางทีเป็น JSON problem details */
|
||||||
|
export async function readErrorMessage(response: Response, fallback: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const text = (await response.text()).trim();
|
||||||
|
if (!text) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown; title?: unknown };
|
||||||
|
const detail = parsed.detail ?? parsed.message ?? parsed.title;
|
||||||
|
if (typeof detail === "string" && detail) {
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// plain text — ใช้ตรงๆ
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET แล้ว parse JSON — fail โยน ApiError พร้อม message จาก backend */
|
||||||
|
export async function apiGetJson<T>(path: string, fallbackError: string): Promise<T> {
|
||||||
|
const response = await apiFetch(path);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(response.status, await readErrorMessage(response, fallbackError));
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST (form/json) แล้ว parse JSON — ใช้กับ upload version ที่ตอบ 201 */
|
||||||
|
export async function apiSendExpectJson<T>(
|
||||||
|
path: string,
|
||||||
|
options: ApiRequestOptions,
|
||||||
|
fallbackError: string
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await apiFetch(path, options);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(response.status, await readErrorMessage(response, fallbackError));
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
97
wordaddin/services/authService.ts
Normal file
97
wordaddin/services/authService.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
/**
|
||||||
|
* MOCK — แทน Keycloak OIDC/PKCE ของต้นฉบับ (ผู้ใช้งานเลือกไม่ใช้ Keycloak ช่วงพอร์ต)
|
||||||
|
* ต้นฉบับ: D:\word-addin-poc\addin\src\services\authService.ts (login ผ่าน Office Dialog API,
|
||||||
|
* token เก็บใน memory เท่านั้น, auto-refresh)
|
||||||
|
*
|
||||||
|
* วันสลับเป็น auth จริง: เขียน implementation ใหม่โดยคง export surface ด้านล่างครบทุกตัว
|
||||||
|
* (เพิ่ม refreshAccessToken ที่ต้นฉบับมีอยู่แล้วสำหรับ apiClient) — โค้ดอื่นไม่ต้องแตะ
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { config } from "../config";
|
||||||
|
|
||||||
|
/** discriminated union เหมือนต้นฉบับ — ok:false ต้องมี error เสมอ */
|
||||||
|
export type LoginResult = { ok: true } | { ok: false; error: string };
|
||||||
|
|
||||||
|
/** state เดียวของ mock — null = signed out */
|
||||||
|
let username: string | null = null;
|
||||||
|
/** token จาก custom properties ของไฟล์ (e-Court pass-through) — เก็บใน memory เท่านั้น */
|
||||||
|
let docToken: string | null = null;
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** จำลองเปิดหน้าต่าง login — สำเร็จทันที ตั้งชื่อจาก NEXT_PUBLIC_ADDIN_MOCK_USERNAME */
|
||||||
|
export async function login(): Promise<LoginResult> {
|
||||||
|
await delay(300);
|
||||||
|
username = config.mockUsername;
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mock-only — โหมด ?addindemo ใช้ตั้ง session ทันทีไม่ผ่านปุ่ม Sign in */
|
||||||
|
export function signInForDemo(): void {
|
||||||
|
username = config.mockUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ถอด field จาก JWT payload (base64url) โดยไม่ตรวจ signature — ใช้ดู uid ประกอบชื่อผู้ใช้เท่านั้น */
|
||||||
|
function decodeJwtField(token: string, field: string): string | undefined {
|
||||||
|
const payload = token.split(".")[1];
|
||||||
|
if (!payload) return undefined;
|
||||||
|
try {
|
||||||
|
// atob คืน binary string (1 char = 1 byte) — ต้องถอด UTF-8 ด้วย TextDecoder ก่อน JSON.parse
|
||||||
|
// ไม่งั้น uid ที่ไม่ใช่ ASCII (เช่น ชื่อไทย) จะกลายเป็น mojibake
|
||||||
|
const bytes = Uint8Array.from(atob(payload.replace(/-/g, "+").replace(/_/g, "/")), (char) => char.charCodeAt(0));
|
||||||
|
const json: unknown = JSON.parse(new TextDecoder().decode(bytes));
|
||||||
|
const value = typeof json === "object" && json !== null ? (json as Record<string, unknown>)[field] : undefined;
|
||||||
|
return typeof value === "string" && value !== "" ? value : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* e-Court pass-through (ระยะแรก) — ไฟล์ที่ e-Court stamp `token`/`owner` ไว้ใน custom properties
|
||||||
|
* ใช้งานได้เลยโดยไม่ต้องกด login: username จาก owner → uid ใน token → mockUsername
|
||||||
|
* คืน true = ผ่านเข้าได้ (มี token หรือ owner อย่างน้อยหนึ่งตัว)
|
||||||
|
*/
|
||||||
|
export function signInFromDocument(input: { token?: string; owner?: string }): boolean {
|
||||||
|
const token = input.token?.trim() || undefined;
|
||||||
|
const owner = input.owner?.trim() || (token ? decodeJwtField(token, "uid") : undefined);
|
||||||
|
if (!token && !owner) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
username = owner ?? config.mockUsername;
|
||||||
|
docToken = token ?? null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccessToken(): Promise<string | null> {
|
||||||
|
if (docToken) return docToken;
|
||||||
|
return username ? `mock-token-${username}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mock: token ไม่มีวันหมดอายุจริง — คืนตัวเดิมหรือ null ตามสถานะ login */
|
||||||
|
export async function refreshAccessToken(): Promise<string | null> {
|
||||||
|
return getAccessToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUsername(): string | undefined {
|
||||||
|
return username ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTokenValid(): boolean {
|
||||||
|
return username !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasRefreshToken(): boolean {
|
||||||
|
return username !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startAutoRefresh(): void {
|
||||||
|
// mock: ไม่มีอะไรต้อง refresh
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signOut(): void {
|
||||||
|
username = null;
|
||||||
|
docToken = null;
|
||||||
|
}
|
||||||
208
wordaddin/services/autoSaveService.ts
Normal file
208
wordaddin/services/autoSaveService.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
/* global console */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FR-17 — Auto Save: บันทึก local (`document.save`) เท่านั้น ห้ามสร้าง version อัตโนมัติ
|
||||||
|
* (version เกิดจากปุ่ม FR-2 เท่านั้น — กัน spam history และรักษา concept ของ FR-13)
|
||||||
|
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\autoSaveService.ts
|
||||||
|
*
|
||||||
|
* - debounce 3 วิ ผ่าน `notifyDocumentChanged()` (ยังไม่มี caller จริงใน Word — ต่อพ่วงไว้ให้ service อื่น)
|
||||||
|
* - periodic ทุก 30 วิ: อ่าน `document.saved` ก่อน — dirty → ตั้ง flag FR-3 + save, สะอาด → ข้าม
|
||||||
|
* - ช่วง save เดียวเท่านั้น (saving lock): มีเรียกซ้อน mark pending แล้วเก็บอีกรอบเมื่อรอบปัจจุบันจบ
|
||||||
|
* - flow ที่ยุ่งกับไฟล์เอง (FR-2/FR-18) ใช้คู่ `suspend()`/`resume()` — ซ้อนกันได้ (นับ depth)
|
||||||
|
* - เริ่มที่ Office.onReady (App mount) ไม่รอ sign-in · ล้ม → `onError` (banner ไม่บล็อก) ไม่ throw ทิ้ง
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isDocumentDirty, saveDocument } from "./documentService";
|
||||||
|
|
||||||
|
export interface AutoSaveConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
debounceMs: number;
|
||||||
|
periodicSaveEnabled: boolean;
|
||||||
|
periodicIntervalMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TS constants ตาม spec §8 — แก้ที่นี่ที่เดียว (ไม่มี runtime config) */
|
||||||
|
export const autoSaveConfig: AutoSaveConfig = {
|
||||||
|
enabled: true,
|
||||||
|
debounceMs: 3_000,
|
||||||
|
periodicSaveEnabled: true,
|
||||||
|
periodicIntervalMs: 30_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
class WordAutoSaveService {
|
||||||
|
private debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private periodicTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
private saving = false;
|
||||||
|
private pendingSave = false;
|
||||||
|
/** depth ของ suspend() — จะรัน timer ใหม่เฉพาะเมื่อ resume ครบทุกชั้น */
|
||||||
|
private suspendDepth = 0;
|
||||||
|
private idleWaiters: Array<() => void> = [];
|
||||||
|
private started = false;
|
||||||
|
private uncommitted = false;
|
||||||
|
|
||||||
|
/** auto-save ล้ม (timer-driven) — App ต่อเป็น banner เตือนแบบไม่บล็อก */
|
||||||
|
onError: ((error: unknown) => void) | undefined;
|
||||||
|
/** save สำเร็จหนึ่งรอบ — App ใช้เคลียร์ banner error เดิม */
|
||||||
|
onSaved: (() => void) | undefined;
|
||||||
|
/** state เปลี่ยน (isSaving / hasUncommittedSinceVersion) — App sync React state */
|
||||||
|
onStateChange: (() => void) | undefined;
|
||||||
|
|
||||||
|
get isSaving(): boolean {
|
||||||
|
return this.saving;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FR-3 — เคยเห็น dirty หลัง version ล่าสุด (เคลียร์เมื่อสร้าง version สำเร็จ) */
|
||||||
|
get hasUncommittedSinceVersion(): boolean {
|
||||||
|
return this.uncommitted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FR-17 §16 — เริ่มตอน Office.onReady ได้เลย ไม่ต้องรอ sign-in */
|
||||||
|
start(): void {
|
||||||
|
if (!autoSaveConfig.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.started = true;
|
||||||
|
this.startTimers();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.started = false;
|
||||||
|
this.stopTimers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** flow ที่จัดการ save เอง (FR-2/FR-18) — หยุด timer + รอ save ค้างจบ แล้วค่อยทำงานต่อ */
|
||||||
|
async suspend(): Promise<void> {
|
||||||
|
this.suspendDepth++;
|
||||||
|
this.stopTimers();
|
||||||
|
await this.waitIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** คู่ของ suspend() — ซ้อนกี่ชั้นต้อง resume ครบก่อน timer ถึงจะกลับมา */
|
||||||
|
resume(): void {
|
||||||
|
if (this.suspendDepth > 0) {
|
||||||
|
this.suspendDepth--;
|
||||||
|
}
|
||||||
|
if (this.suspendDepth === 0) {
|
||||||
|
this.startTimers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FR-17 debounce — service ที่แก้เอกสารเรียกหลัง operation สำเร็จ */
|
||||||
|
notifyDocumentChanged(): void {
|
||||||
|
if (!autoSaveConfig.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.clearDebounce();
|
||||||
|
this.debounceTimer = setTimeout(() => {
|
||||||
|
this.debounceTimer = undefined;
|
||||||
|
this.save().catch((error) => this.reportError(error));
|
||||||
|
}, autoSaveConfig.debounceMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** บันทึกเดี๋ยวนี้ (ยกเลิก debounce ค้าง) — ถ้ามี save กำลังทำงานจะ mark pending แทน (coalesce) */
|
||||||
|
async save(): Promise<void> {
|
||||||
|
this.clearDebounce();
|
||||||
|
if (this.saving) {
|
||||||
|
this.pendingSave = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.saving = true;
|
||||||
|
this.notifyState();
|
||||||
|
try {
|
||||||
|
await saveDocument();
|
||||||
|
if (this.pendingSave) {
|
||||||
|
this.pendingSave = false;
|
||||||
|
await saveDocument();
|
||||||
|
}
|
||||||
|
this.onSaved?.();
|
||||||
|
} finally {
|
||||||
|
this.pendingSave = false;
|
||||||
|
this.saving = false;
|
||||||
|
this.notifyState();
|
||||||
|
this.resolveIdle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** บันทึกให้เสร็จก่อนกลับ — ใช้ก่อน close/upload (spec auto-save ข้อ 12) เรียกหลัง suspend() เสมอ */
|
||||||
|
async forceSave(): Promise<void> {
|
||||||
|
this.clearDebounce();
|
||||||
|
await this.waitIdle();
|
||||||
|
await this.save();
|
||||||
|
await this.waitIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FR-3 — version ใหม่ถูกสร้างสำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version" */
|
||||||
|
clearUncommittedSinceVersion(): void {
|
||||||
|
this.uncommitted = false;
|
||||||
|
this.notifyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startTimers(): void {
|
||||||
|
if (!this.started || !autoSaveConfig.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (autoSaveConfig.periodicSaveEnabled && !this.periodicTimer) {
|
||||||
|
this.periodicTimer = setInterval(() => {
|
||||||
|
void this.periodicTick();
|
||||||
|
}, autoSaveConfig.periodicIntervalMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopTimers(): void {
|
||||||
|
this.clearDebounce();
|
||||||
|
if (this.periodicTimer) {
|
||||||
|
clearInterval(this.periodicTimer);
|
||||||
|
this.periodicTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearDebounce(): void {
|
||||||
|
if (this.debounceTimer) {
|
||||||
|
clearTimeout(this.debounceTimer);
|
||||||
|
this.debounceTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async periodicTick(): Promise<void> {
|
||||||
|
if (this.saving || this.suspendDepth > 0) {
|
||||||
|
return; // มี save หรือ flow อื่นถือไม้ตายอยู่ — รอรอบหน้า
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// FR-3 — periodic ทำหน้าที่ reminder ด้วย: เห็น dirty ก่อน save ตั้ง flag
|
||||||
|
// (ระบบ reminder เดิมที่ poll แยกถูกแทนด้วย tick นี้)
|
||||||
|
if (!(await isDocumentDirty())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.uncommitted = true;
|
||||||
|
this.notifyState();
|
||||||
|
await this.save();
|
||||||
|
} catch (error) {
|
||||||
|
this.reportError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private waitIdle(): Promise<void> {
|
||||||
|
return this.saving
|
||||||
|
? new Promise<void>((resolve) => this.idleWaiters.push(resolve))
|
||||||
|
: Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveIdle(): void {
|
||||||
|
const waiters = this.idleWaiters;
|
||||||
|
this.idleWaiters = [];
|
||||||
|
for (const waiter of waiters) {
|
||||||
|
waiter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyState(): void {
|
||||||
|
this.onStateChange?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportError(error: unknown): void {
|
||||||
|
console.warn("auto-save failed:", error);
|
||||||
|
this.onError?.(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const autoSaveService = new WordAutoSaveService();
|
||||||
86
wordaddin/services/documentService.ts
Normal file
86
wordaddin/services/documentService.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
/* global Word, Office */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FR-17 / FR-18 — จุดเดียวที่คุยกับ document-level API ของ Word
|
||||||
|
* (native save / อ่าน dirty / ปิดเอกสาร) แยกจาก versionFlow เพื่อให้
|
||||||
|
* autoSaveService กับปุ่ม [บันทึก] ใช้ร่วมได้โดยไม่ต้องพ่วง flow versioning
|
||||||
|
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\documentService.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum DocumentCloseMode {
|
||||||
|
/** บันทึกการแก้ไขก่อนปิด */
|
||||||
|
Save = "Save",
|
||||||
|
/** ปิดโดยไม่บันทึก — ไว้ใช้ระดับ service เท่านั้น (UI ปัจจุบันไม่มีปุ่มไหนใช้) */
|
||||||
|
SkipSave = "SkipSave",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** native save ตาม FR-2 ขั้นตอนที่ 4 — ใช้ร่วมทั้ง versionFlow และ autoSaveService
|
||||||
|
* เรียกแบบไม่ส่ง saveBehavior: ปลอดภัยทุกรุ่น runtime — รุ่นเก่า (npm 1.1.110) ไม่มี
|
||||||
|
* enum Word.SaveBehavior และรับ save() แบบไม่มี argument, รุ่นใหม่ param เป็น optional
|
||||||
|
* ที่ default เดียวกัน (behavior = save เงียบ ๆ) */
|
||||||
|
export async function saveDocument(): Promise<void> {
|
||||||
|
await Word.run(async (context) => {
|
||||||
|
context.document.save();
|
||||||
|
await context.sync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FR-3 — มีการแก้ไขที่ยังไม่ถูกบันทึกลงไฟล์ local หรือไม่ */
|
||||||
|
export async function isDocumentDirty(): Promise<boolean> {
|
||||||
|
return Word.run(async (context) => {
|
||||||
|
context.document.load("saved");
|
||||||
|
await context.sync();
|
||||||
|
return !context.document.saved;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** save แล้วเช็คว่า Word เห็นไฟล์สะอาดจริง — บางสถานะ (เช่น หลังเขียน custom properties /
|
||||||
|
* custom XML part ของ flow versioning) Word ยังรายงาน saved=false แม้ save ผ่านแล้ว
|
||||||
|
* จึง save ซ้ำแบบมีขอบเขต (รวมสูงสุด 3 รอบ) — ไม่งั้น periodic tick ของ auto-save
|
||||||
|
* เห็น dirty ตอนไม่มีใครแก้อะไร แล้ว banner FR-3 ("มีการแก้ไขที่ยังไม่เป็น version")
|
||||||
|
* โผล่ผิด ๆ หลังจบ flow */
|
||||||
|
export async function saveDocumentVerified(): Promise<void> {
|
||||||
|
await saveDocument();
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
|
if (!(await isDocumentDirty())) return;
|
||||||
|
await saveDocument();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FR-18 — `document.close()` ต้องการ WordApi 1.5 **และใช้ไม่ได้บน Word on the web**
|
||||||
|
* (requirement-set table ระบุรองรับ แต่หน้าเอกสารของ method เขียนไว้ว่า "isn't
|
||||||
|
* supported in Word on the web") จึงต้องเช็ค platform คู่กันเสมอ —
|
||||||
|
* UI ใช้ซ่อนปุ่ม close ทุกแบบ, service ใช้ guard ก่อนเรียกจริง
|
||||||
|
* (เรียกนอก Word — เช่น SSR หรือ browser ธรรมดา — throw ถูก catch คืน false เสมอ)
|
||||||
|
*/
|
||||||
|
export function canCloseDocument(): boolean {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
Office.context.requirements.isSetSupported("WordApi", "1.5") &&
|
||||||
|
Office.context.platform !== Office.PlatformType.OfficeOnline
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FR-18 — ปิดเอกสารปัจจุบัน (task pane จะปิดตามเอกสาร) */
|
||||||
|
export async function closeDocument(mode: DocumentCloseMode = DocumentCloseMode.Save): Promise<void> {
|
||||||
|
if (!canCloseDocument()) {
|
||||||
|
throw new Error(
|
||||||
|
"เวอร์ชัน Word นี้ไม่รองรับการปิดเอกสารจาก add-in (รองรับเฉพาะ Word Desktop ที่ใช้ WordApi 1.5 ได้)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await Word.run(async (context) => {
|
||||||
|
// ส่งค่า underlying ของ enum เป็น string ตรง ๆ ไม่อ่าน Word.CloseBehavior.* —
|
||||||
|
// runtime self-host อาจเป็นรุ่นเก่ากว่า @types (กรณี npm 1.1.110 เดิม: ไม่มี enum นี้เลย
|
||||||
|
// อ่านแล้ว TypeError "reading 'save'"; ปัจจุบัน fetch ฉบับ CDN ล่าสุดมี enum แล้วแต่ค่า
|
||||||
|
// string ทำงานเหมือนกันทุกรุ่น — cast กัน @types ที่พิมพ์ parameter เป็น enum object)
|
||||||
|
context.document.close(
|
||||||
|
(mode === DocumentCloseMode.Save ? "Save" : "SkipSave") as Word.CloseBehavior
|
||||||
|
);
|
||||||
|
// ห้ามเรียก context.sync() หลัง close() — เอกสารปิดกลาง batch ทำให้ sync ถูก abort
|
||||||
|
// (ตัวอย่างทางการของ Word เรียก close เฉยๆ แล้วพึ่ง implicit sync ตอน run() จบ)
|
||||||
|
});
|
||||||
|
}
|
||||||
10
wordaddin/services/errors.ts
Normal file
10
wordaddin/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;
|
||||||
|
}
|
||||||
128
wordaddin/services/fileService.ts
Normal file
128
wordaddin/services/fileService.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
/* global Office, console */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ดึงไฟล์ .docx ปัจจุบันผ่าน File/Slice API (FR-2 ขั้นตอนที่ 5)
|
||||||
|
* กติกา: Word เปิด File object พร้อมกันได้แค่ 2 ตัว → เรียก closeAsync เสมอใน finally
|
||||||
|
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\fileService.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DOCX_CONTENT_TYPE =
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
|
||||||
|
/** ขนาด slice ต่อครั้ง — 1MB กันกิน memory ยักษ์ในเครื่องรายงานน้อย */
|
||||||
|
export const PDF_CONTENT_TYPE = "application/pdf";
|
||||||
|
|
||||||
|
const SLICE_SIZE = 1024 * 1024;
|
||||||
|
|
||||||
|
function officeError(result: Office.AsyncResult<unknown>, fallback: string): Error {
|
||||||
|
return new Error(result.error?.message || fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileAsync(fileType: Office.FileType): Promise<Office.File> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
Office.context.document.getFileAsync(fileType, { sliceSize: SLICE_SIZE }, (result) => {
|
||||||
|
if (result.status === Office.AsyncResultStatus.Succeeded) {
|
||||||
|
resolve(result.value);
|
||||||
|
} else {
|
||||||
|
reject(officeError(result, "เปิดไฟล์เอกสารไม่สำเร็จ"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(error instanceof Error ? error : new Error("เปิดไฟล์เอกสารไม่สำเร็จ"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
try {
|
||||||
|
resolve(sliceDataToBytes(result.value.data));
|
||||||
|
} catch (error) {
|
||||||
|
reject(
|
||||||
|
error instanceof Error ? error : new Error(`อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject(officeError(result, `อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(error instanceof Error ? error : new Error(`อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ปิด File object — fail ก็แค่ warn ไม่ทับ error ของ flow หลัก */
|
||||||
|
function closeFileQuietly(file: Office.File): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
file.closeAsync(() => resolve());
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("closeAsync failed:", error);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ประกอบไฟล์ทั้งไฟล์เป็น Blob — อ่าน slice ทีละชิ้นตามลำดับ
|
||||||
|
* เรียก closeAsync เสมอ (สำเร็จหรือ fail ก็ตาม)
|
||||||
|
*/
|
||||||
|
async function readFileAsBlob(fileType: Office.FileType, contentType: string): Promise<Blob> {
|
||||||
|
const file = await getFileAsync(fileType);
|
||||||
|
try {
|
||||||
|
const chunks: BlobPart[] = [];
|
||||||
|
for (let index = 0; index < file.sliceCount; index++) {
|
||||||
|
chunks.push(await getSliceAsync(file, index));
|
||||||
|
}
|
||||||
|
return new Blob(chunks, { type: contentType });
|
||||||
|
} finally {
|
||||||
|
await closeFileQuietly(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentDocumentFile(): Promise<Blob> {
|
||||||
|
return readFileAsBlob(Office.FileType.Compressed, DOCX_CONTENT_TYPE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentDocumentPdf(): Promise<Blob> {
|
||||||
|
if (!Office.context.requirements.isSetSupported("PdfFile", "1.1")) {
|
||||||
|
throw new Error("Word นี้ไม่รองรับการสร้าง PDF");
|
||||||
|
}
|
||||||
|
return readFileAsBlob(Office.FileType.Pdf, PDF_CONTENT_TYPE);
|
||||||
|
}
|
||||||
256
wordaddin/services/metadataService.ts
Normal file
256
wordaddin/services/metadataService.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
/* global Word, console */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata ในไฟล์ .docx ที่ add-in อ่าน/เขียน (spec ส่วน 8):
|
||||||
|
* - Custom Document Properties = primary flag ตรวจจับ tracked document
|
||||||
|
* - Custom XML Part (ns `urn:myaddin:versioning`) = secondary เท่านั้น — backend คือ source of truth
|
||||||
|
* - readContentControlFields (FR-11) อ่านค่า content controls เป็น map tag→text
|
||||||
|
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\metadataService.ts
|
||||||
|
*
|
||||||
|
* กติกา: ทุก Word.run ต้องจบด้วย context.sync(); XML part ฝั่งเขียน **ห้าม throw**
|
||||||
|
* (known bug บน Mac บาง build — เขียนไม่ได้ก็ปล่อยผ่าน)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** GUID คงที่ของ add-in — ต้องตรงเป๊ะกับ `AddInMetadata.AddInSignatureGuid` ฝั่ง backend (spec ส่วน 8) */
|
||||||
|
export const ADDIN_SIGNATURE_GUID = "9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D";
|
||||||
|
|
||||||
|
export const CUSTOM_XML_NAMESPACE = "urn:myaddin:versioning";
|
||||||
|
|
||||||
|
export const SAVED_BY_ADD_IN_PROPERTY = "SavedByAddIn";
|
||||||
|
export const DOCUMENT_TRACKING_ID_PROPERTY = "DocumentTrackingId";
|
||||||
|
export const LAST_VERSION_ID_PROPERTY = "LastVersionId";
|
||||||
|
export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp";
|
||||||
|
/** FR-16 — stamp เฉพาะไฟล์ร่าง (draft) ที่เว็บสร้าง: template ต้นทาง (ค่าเดียวกับ AddInMetadata.TemplateIdPropertyName ฝั่ง backend) */
|
||||||
|
export const TEMPLATE_ID_PROPERTY = "TemplateId";
|
||||||
|
|
||||||
|
/** e-Court pass-through — ไฟล์ที่ e-Court stamp มาใน custom properties เข้าใช้ได้เลยไม่ต้องกด login */
|
||||||
|
export const ECOURT_TOKEN_PROPERTY = "token";
|
||||||
|
export const ECOURT_OWNER_PROPERTY = "owner";
|
||||||
|
|
||||||
|
/** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */
|
||||||
|
export interface TrackedDocumentProperties {
|
||||||
|
savedByAddIn?: string;
|
||||||
|
documentTrackingId?: string;
|
||||||
|
lastVersionId?: string;
|
||||||
|
lastVersionTimestamp?: string;
|
||||||
|
/** มีเฉพาะไฟล์ร่าง (FR-16) — ส่งต่อให้ backend ตอน Start Versioning เพื่อผูก Document กับ template */
|
||||||
|
templateId?: string;
|
||||||
|
/** e-Court pass-through — มีอย่างใดอย่างหนึ่ง = ข้ามหน้า login (username จาก owner หรือ uid ใน token) */
|
||||||
|
ecourtToken?: string;
|
||||||
|
ecourtOwner?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** item ใน Custom XML Part `<VersionHistory>` */
|
||||||
|
export interface XmlVersionEntry {
|
||||||
|
id: string;
|
||||||
|
number: number;
|
||||||
|
timestamp: string;
|
||||||
|
user: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** อ่าน custom properties ทั้ง 7 ตัวที่ระบบสนใจ (4 ตัวของ tracked + TemplateId ของไฟล์ร่าง FR-16
|
||||||
|
* + token/owner ที่ e-Court stamp มากับไฟล์) */
|
||||||
|
export async function readTrackedProperties(): Promise<TrackedDocumentProperties> {
|
||||||
|
return Word.run(async (context) => {
|
||||||
|
const properties = context.document.properties.customProperties;
|
||||||
|
const savedByAddIn = properties.getItemOrNullObject(SAVED_BY_ADD_IN_PROPERTY);
|
||||||
|
const documentTrackingId = properties.getItemOrNullObject(DOCUMENT_TRACKING_ID_PROPERTY);
|
||||||
|
const lastVersionId = properties.getItemOrNullObject(LAST_VERSION_ID_PROPERTY);
|
||||||
|
const lastVersionTimestamp = properties.getItemOrNullObject(LAST_VERSION_TIMESTAMP_PROPERTY);
|
||||||
|
const templateId = properties.getItemOrNullObject(TEMPLATE_ID_PROPERTY);
|
||||||
|
const ecourtToken = properties.getItemOrNullObject(ECOURT_TOKEN_PROPERTY);
|
||||||
|
const ecourtOwner = properties.getItemOrNullObject(ECOURT_OWNER_PROPERTY);
|
||||||
|
// load "value" ของ null-object proxy ก็ทำให้ isNullObject แม่นหลัง sync
|
||||||
|
savedByAddIn.load("value");
|
||||||
|
documentTrackingId.load("value");
|
||||||
|
lastVersionId.load("value");
|
||||||
|
lastVersionTimestamp.load("value");
|
||||||
|
templateId.load("value");
|
||||||
|
ecourtToken.load("value");
|
||||||
|
ecourtOwner.load("value");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
const read = (property: Word.CustomProperty): string | undefined =>
|
||||||
|
property.isNullObject || property.value === undefined || property.value === null
|
||||||
|
? undefined
|
||||||
|
: String(property.value);
|
||||||
|
|
||||||
|
return {
|
||||||
|
savedByAddIn: read(savedByAddIn),
|
||||||
|
documentTrackingId: read(documentTrackingId),
|
||||||
|
lastVersionId: read(lastVersionId),
|
||||||
|
lastVersionTimestamp: read(lastVersionTimestamp),
|
||||||
|
templateId: read(templateId),
|
||||||
|
ecourtToken: read(ecourtToken),
|
||||||
|
ecourtOwner: read(ecourtOwner),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FR-1 — ไฟล์นี้ผ่าน add-in นี้หรือเปล่า (รวมไฟล์ที่ generate จากเว็บ ที่ backend stamp ไว้แล้ว)
|
||||||
|
* หมายเหตุ: TaskpaneApp.detect() ทำนายค่านี้ inline จาก readTrackedProperties() อยู่แล้ว (ต้องอ่าน properties
|
||||||
|
* ครั้งเดียวทั้งชุดเพื่อให้ไฟล์ร่าง untracked ได้อ่าน TemplateId ด้วย — FR-16) ฟังก์ชันนี้จึงเหลือเป็น
|
||||||
|
* service API ที่มี test คุม semantics ของ FR-1 ไว้ */
|
||||||
|
export async function isTrackedDocument(): Promise<boolean> {
|
||||||
|
const properties = await readTrackedProperties();
|
||||||
|
return properties.savedByAddIn === ADDIN_SIGNATURE_GUID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StampPropertiesInput {
|
||||||
|
/** tracking id ของเอกสาร — ส่งเฉพาะตอนสร้างใหม่ (Start Versioning) หรือตอน stamp รอบแรก */
|
||||||
|
documentTrackingId?: string;
|
||||||
|
lastVersionId?: string;
|
||||||
|
lastVersionTimestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* เขียน/stamp custom properties — ใช้ add() ทับของเดิม (add กับ key ที่มีอยู่ = update ค่า
|
||||||
|
* ต่างจาก XML part ที่ต้อง delete ก่อน) พร้อมกันทั้งชุดใน Word.run เดียว
|
||||||
|
*/
|
||||||
|
export async function stampTrackedProperties(input: StampPropertiesInput): Promise<void> {
|
||||||
|
await Word.run(async (context) => {
|
||||||
|
const properties = context.document.properties.customProperties;
|
||||||
|
properties.add(SAVED_BY_ADD_IN_PROPERTY, ADDIN_SIGNATURE_GUID);
|
||||||
|
if (input.documentTrackingId !== undefined) {
|
||||||
|
properties.add(DOCUMENT_TRACKING_ID_PROPERTY, input.documentTrackingId);
|
||||||
|
}
|
||||||
|
if (input.lastVersionId !== undefined) {
|
||||||
|
properties.add(LAST_VERSION_ID_PROPERTY, input.lastVersionId);
|
||||||
|
}
|
||||||
|
if (input.lastVersionTimestamp !== undefined) {
|
||||||
|
properties.add(LAST_VERSION_TIMESTAMP_PROPERTY, input.lastVersionTimestamp);
|
||||||
|
}
|
||||||
|
await context.sync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GUID ใหม่สำหรับ tracking id / clientVersionId */
|
||||||
|
export function generateGuid(): string {
|
||||||
|
if (typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom XML Part — secondary history (ไม่ใช่ source of truth)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function escapeXmlAttribute(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** สร้าง XML ของ `<VersionHistory>` ตามรูปใน spec ส่วน 8 */
|
||||||
|
export function buildVersionHistoryXml(entries: XmlVersionEntry[]): string {
|
||||||
|
const elements = entries
|
||||||
|
.map(
|
||||||
|
(entry) =>
|
||||||
|
` <Version id="${escapeXmlAttribute(entry.id)}" number="${entry.number}" timestamp="${escapeXmlAttribute(
|
||||||
|
entry.timestamp
|
||||||
|
)}" user="${escapeXmlAttribute(entry.user)}"/>`
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
return `<VersionHistory xmlns="${CUSTOM_XML_NAMESPACE}">\n${elements}\n</VersionHistory>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersionHistoryXml(xml: string): XmlVersionEntry[] {
|
||||||
|
const doc = new DOMParser().parseFromString(xml, "application/xml");
|
||||||
|
const nodes = Array.from(doc.getElementsByTagNameNS(CUSTOM_XML_NAMESPACE, "Version"));
|
||||||
|
return nodes
|
||||||
|
.map((node) => ({
|
||||||
|
id: node.getAttribute("id") ?? "",
|
||||||
|
number: Number(node.getAttribute("number") ?? Number.NaN),
|
||||||
|
timestamp: node.getAttribute("timestamp") ?? "",
|
||||||
|
user: node.getAttribute("user") ?? "",
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.id !== "" && Number.isFinite(entry.number));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** อ่าน compact history จาก XML part — fail อ่านเป็น [] เสมอ (ไม่ throw) */
|
||||||
|
export async function readVersionHistoryFromXml(): Promise<XmlVersionEntry[]> {
|
||||||
|
try {
|
||||||
|
return await Word.run(async (context) => {
|
||||||
|
const scoped = context.document.customXmlParts.getByNamespace(CUSTOM_XML_NAMESPACE);
|
||||||
|
context.load(scoped);
|
||||||
|
await context.sync();
|
||||||
|
const xmlResults = scoped.items.map((part) => part.getXml());
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
const entries: XmlVersionEntry[] = [];
|
||||||
|
for (const result of xmlResults) {
|
||||||
|
entries.push(...parseVersionHistoryXml(result.value));
|
||||||
|
}
|
||||||
|
entries.sort((a, b) => b.number - a.number); // ใหม่ → เก่า
|
||||||
|
return entries;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("readVersionHistoryFromXml failed (secondary storage — ปล่อยผ่าน):", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* เขียน XML part ด้วย pattern delete-then-add (แก้ part เดิมตรงๆ ไม่ได้)
|
||||||
|
* ⚠️ ห้าม throw แม้จะล้มเหลว — known bug บน Word for Mac บาง build
|
||||||
|
* คืน true = เขียนสำเร็จ, false = fail แบบ silent (backend ยังเป็น source of truth อยู่ดี — AC-10)
|
||||||
|
*/
|
||||||
|
export async function writeVersionHistoryToXml(entries: XmlVersionEntry[]): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await Word.run(async (context) => {
|
||||||
|
// 1) ลบ part เดิมใน namespace นี้ทั้งหมดก่อน
|
||||||
|
const scoped = context.document.customXmlParts.getByNamespace(CUSTOM_XML_NAMESPACE);
|
||||||
|
context.load(scoped);
|
||||||
|
await context.sync();
|
||||||
|
for (const part of scoped.items) {
|
||||||
|
part.delete();
|
||||||
|
}
|
||||||
|
if (scoped.items.length > 0) {
|
||||||
|
await context.sync();
|
||||||
|
}
|
||||||
|
// 2) add ของใหม่
|
||||||
|
context.document.customXmlParts.add(buildVersionHistoryXml(entries));
|
||||||
|
await context.sync();
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("writeVersionHistoryToXml failed (silent per spec — known Mac bug):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Content controls → fields (FR-11)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* อ่าน content controls ทั้งเอกสารเป็น map `{ [tag]: text }` สำหรับแนบกับ upload version
|
||||||
|
* - ไม่มี content controls / tag ว่าง → ข้าม (คืน {} ไม่ error)
|
||||||
|
* - tag ซ้ำ → เอาตัวแรกตามลำดับในเอกสาร
|
||||||
|
*/
|
||||||
|
export async function readContentControlFields(): Promise<Record<string, string>> {
|
||||||
|
return Word.run(async (context) => {
|
||||||
|
const controls = context.document.contentControls;
|
||||||
|
controls.load("tag, text");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
for (const control of controls.items) {
|
||||||
|
const tag = (control.tag ?? "").trim();
|
||||||
|
if (tag === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(fields, tag)) {
|
||||||
|
fields[tag] = control.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
});
|
||||||
|
}
|
||||||
229
wordaddin/services/versionFlow.ts
Normal file
229
wordaddin/services/versionFlow.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
/* global Office, console */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestration ของ flow หลักสองอันของ add-in (แยกจาก UI เพื่อ unit test ได้):
|
||||||
|
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\versionFlow.ts
|
||||||
|
*
|
||||||
|
* - `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 จริงอยู่ดี)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as authService from "./authService";
|
||||||
|
import { autoSaveService } from "./autoSaveService";
|
||||||
|
import { DocumentCloseMode, closeDocument, saveDocumentVerified } from "./documentService";
|
||||||
|
import { getCurrentDocumentFile, getCurrentDocumentPdf } from "./fileService";
|
||||||
|
import {
|
||||||
|
generateGuid,
|
||||||
|
readContentControlFields,
|
||||||
|
readVersionHistoryFromXml,
|
||||||
|
stampTrackedProperties,
|
||||||
|
writeVersionHistoryToXml,
|
||||||
|
XmlVersionEntry,
|
||||||
|
} from "./metadataService";
|
||||||
|
import { uploadVersion } from "./versioningService";
|
||||||
|
|
||||||
|
export interface StartVersioningResult {
|
||||||
|
hasPdf: boolean;
|
||||||
|
documentId: string;
|
||||||
|
versionId: string;
|
||||||
|
versionNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveAndVersionResult {
|
||||||
|
hasPdf: boolean;
|
||||||
|
versionId: string;
|
||||||
|
versionNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FR-18 — version ถูกบันทึกสำเร็จแล้วแต่ปิดเอกสารไม่สำเร็จ (เอกสารยังเปิดอยู่)
|
||||||
|
* UI ต้องแยกสถานะนี้ออกจาก "สร้าง version ไม่สำเร็จ" — version ปลอดภัยบน backend แล้ว
|
||||||
|
*/
|
||||||
|
export class VersionSavedButCloseFailedError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly result: SaveAndVersionResult,
|
||||||
|
cause: unknown
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
`บันทึก version ${result.versionNumber} เรียบร้อย แต่ปิดเอกสารไม่สำเร็จ: ${
|
||||||
|
cause instanceof Error ? cause.message : String(cause)
|
||||||
|
} — ปิดเอกสารเองได้เลย`
|
||||||
|
);
|
||||||
|
this.name = "VersionSavedButCloseFailedError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ชื่อไฟล์จาก 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()` ย้ายไป documentService (ใช้ร่วมกับ FR-17 auto-save) */
|
||||||
|
|
||||||
|
async function uploadCurrentFile(
|
||||||
|
documentId: string,
|
||||||
|
clientVersionId: string,
|
||||||
|
fields: Record<string, string>,
|
||||||
|
message?: string,
|
||||||
|
onProgress?: (progress: string) => void,
|
||||||
|
templateId?: string
|
||||||
|
): Promise<SaveAndVersionResult> {
|
||||||
|
const file = await getCurrentDocumentFile();
|
||||||
|
let pdf: Blob | undefined;
|
||||||
|
onProgress?.("กำลังสร้างสำเนา PDF…");
|
||||||
|
try {
|
||||||
|
pdf = await getCurrentDocumentPdf();
|
||||||
|
if (pdf.size === 0) throw new Error("Word คืนไฟล์ PDF ว่าง");
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("PDF capture failed:", error);
|
||||||
|
pdf = undefined;
|
||||||
|
}
|
||||||
|
onProgress?.("กำลังอัปโหลด version…");
|
||||||
|
return uploadVersion(
|
||||||
|
documentId,
|
||||||
|
file,
|
||||||
|
clientVersionId,
|
||||||
|
fields,
|
||||||
|
guessDocumentFileName(),
|
||||||
|
message,
|
||||||
|
pdf,
|
||||||
|
templateId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1
|
||||||
|
* (FR-13: message คือ commit message ทางเลือกจาก dialog ยืนยัน)
|
||||||
|
* (FR-16: templateId ส่งเฉพาะไฟล์ร่างที่เว็บสร้าง — backend ผูก Document กับ template ต้นทางตอน auto-create) */
|
||||||
|
export async function startVersioning(
|
||||||
|
message?: string,
|
||||||
|
templateId?: string,
|
||||||
|
onProgress?: (progress: string) => void
|
||||||
|
): Promise<StartVersioningResult> {
|
||||||
|
// FR-17 — หยุด auto-save ตลอด flow กัน save แทรกกลาง stamp/capture แล้วค่อนคืนตอนจบ
|
||||||
|
await autoSaveService.suspend();
|
||||||
|
try {
|
||||||
|
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 saveDocumentVerified();
|
||||||
|
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
|
||||||
|
const fields = await readContentControlFields();
|
||||||
|
|
||||||
|
// 4) upload version แรก (แนบ templateId เมื่อเป็นไฟล์ร่าง — FR-16)
|
||||||
|
onProgress?.("กำลังอัปโหลด version 1…");
|
||||||
|
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress, templateId);
|
||||||
|
// FR-3 — สร้าง version สำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version"
|
||||||
|
autoSaveService.clearUncommittedSinceVersion();
|
||||||
|
return { documentId, ...result };
|
||||||
|
} finally {
|
||||||
|
autoSaveService.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FR-2 — Save & Version ของเอกสารที่ track อยู่แล้ว (เรียกหลังผู้ใช้กดยืนยันใน dialog แล้ว)
|
||||||
|
* (FR-13: message คือ commit message ทางเลือกจาก dialog ยืนยัน)
|
||||||
|
* (FR-18: closeAfter=true — หลัง upload สำเร็จเท่านั้น ปิดเอกสารด้วย close(Save);
|
||||||
|
* ปิดไม่สำเร็จโยน VersionSavedButCloseFailedError ไม่ใช่ error ของ version) */
|
||||||
|
export async function saveAndVersion(
|
||||||
|
documentId: string,
|
||||||
|
latestVersionNumber: number,
|
||||||
|
message?: string,
|
||||||
|
onProgress?: (progress: string) => void,
|
||||||
|
closeAfter = false
|
||||||
|
): Promise<SaveAndVersionResult> {
|
||||||
|
// FR-17 — หยุด auto-save ตลอด flow กัน save แทรกกลาง stamp/capture แล้วค่อนคืนตอนจบ
|
||||||
|
await autoSaveService.suspend();
|
||||||
|
try {
|
||||||
|
const clientVersionId = generateGuid();
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const predictedNumber = latestVersionNumber + 1;
|
||||||
|
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 saveDocumentVerified();
|
||||||
|
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
|
||||||
|
const fields = await readContentControlFields();
|
||||||
|
|
||||||
|
// 4) upload
|
||||||
|
onProgress?.("กำลังอัปโหลด version ใหม่…");
|
||||||
|
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FR-3 — สร้าง version สำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version"
|
||||||
|
autoSaveService.clearUncommittedSinceVersion();
|
||||||
|
|
||||||
|
// 6) FR-18 — ปิดเอกสารเฉพาะเมื่อ upload สำเร็จแล้วเท่านั้น (ต้องอยู่หลังขั้น 5 เพราะหลัง close
|
||||||
|
// เอกสารหมดสิทธิ์เรียก Word.run ต่อ)
|
||||||
|
if (closeAfter) {
|
||||||
|
try {
|
||||||
|
await closeDocument(DocumentCloseMode.Save);
|
||||||
|
} catch (error) {
|
||||||
|
throw new VersionSavedButCloseFailedError(result, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
autoSaveService.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
154
wordaddin/services/versioningService.ts
Normal file
154
wordaddin/services/versioningService.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
/**
|
||||||
|
* MOCK — version API ฝั่ง client (รอ backend จริงตาม contract ใน docs/addin-ui-flow.md §7)
|
||||||
|
* ต้นฉบับ: D:\word-addin-poc\addin\src\services\versioningService.ts (multipart POST ผ่าน apiClient)
|
||||||
|
*
|
||||||
|
* - คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId ไว้เป๊ะ —
|
||||||
|
* วันปลด mock: ก้อบ body ของต้นฉบับกลับมาได้เลย
|
||||||
|
* - ต้นฉบับมีอีก 2 ตัวที่ไม่มี caller ใน UI จึงไม่พอร์ต: buildDownloadUrl, downloadVersion
|
||||||
|
* - **metadata ของ version ถูก persist ลง localStorage** (record เล็ก ๆ ต่อ documentId) —
|
||||||
|
* ไม่งั้นปิดเอกสาร (taskpane ปิดตาม) แล้วเปิดใหม่ history ว่างเปล่า ทั้งที่ metadata
|
||||||
|
* การ track อยู่ในไฟล์จริง ทำให้ทดสอบวง FR-18 ปิด→เปิดใหม่ไม่ได้; blob ของไฟล์จริง
|
||||||
|
* ยังเก็บเฉพาะ session (mock download คืน about:blank อยู่แล้ว) — localStorage เข้า
|
||||||
|
* ไม่ได้ (webview บางตัว block) = ใช้ memory อย่างเดียวแบบเดิม ไม่ throw
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as authService from "./authService";
|
||||||
|
import { generateGuid } from "./metadataService";
|
||||||
|
import { UploadVersionResult, VersionRecord } from "../models/versionRecord";
|
||||||
|
|
||||||
|
interface StoredVersion {
|
||||||
|
record: VersionRecord;
|
||||||
|
docxBlob: Blob | null;
|
||||||
|
pdfBlob: Blob | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** key = documentId, เก็บเรียงเก่า → ใหม่ */
|
||||||
|
const store = new Map<string, StoredVersion[]>();
|
||||||
|
|
||||||
|
const STORAGE_KEY = "wordaddin-mock-versions";
|
||||||
|
|
||||||
|
/** โหลด record ที่ persist ไว้ (เฉพาะ metadata — blob ไม่ข้าม session) — พังใด ๆ = เริ่มว่าง */
|
||||||
|
function loadPersisted(): Record<string, VersionRecord[]> {
|
||||||
|
try {
|
||||||
|
if (typeof window === "undefined") return {}; // SSR ของ client component — ไม่มี localStorage
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
const parsed = raw ? (JSON.parse(raw) as Record<string, VersionRecord[]>) : {};
|
||||||
|
return parsed && typeof parsed === "object" ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const persisted = loadPersisted();
|
||||||
|
for (const [documentId, records] of Object.entries(persisted)) {
|
||||||
|
if (Array.isArray(records)) {
|
||||||
|
store.set(documentId, records.map((record) => ({ record, docxBlob: null, pdfBlob: null })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** บันทึกเฉพาะ metadata กลับลง localStorage — พัง = เงียบ (ยังมี memory ของ session นี้อยู่) */
|
||||||
|
function persist(): void {
|
||||||
|
try {
|
||||||
|
const snapshot: Record<string, VersionRecord[]> = {};
|
||||||
|
store.forEach((versions, documentId) => {
|
||||||
|
snapshot[documentId] = versions.map((item) => item.record);
|
||||||
|
});
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||||
|
} catch {
|
||||||
|
// ignore — quota/private mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SHA-256 จริงของไฟล์ที่ได้จาก Word — พังก็ไม่ throw (ใช้ค่าหยง ๆ ให้ UI ยังไปต่อได้) */
|
||||||
|
async function mockContentHash(file: Blob): Promise<string> {
|
||||||
|
try {
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
||||||
|
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
} catch {
|
||||||
|
return "mock-hash";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (mock: เก็บลง memory)
|
||||||
|
* + FR-13: message คือ commit message ทางเลือก
|
||||||
|
* + FR-16: templateId เฉพาะไฟล์ร่าง (mock ไม่ใช้ — คงไว้ตาม signature ต้นฉบับ) */
|
||||||
|
export async function uploadVersion(
|
||||||
|
documentId: string,
|
||||||
|
file: Blob,
|
||||||
|
clientVersionId: string,
|
||||||
|
fields?: Record<string, string>,
|
||||||
|
fileName?: string,
|
||||||
|
message?: string,
|
||||||
|
pdf?: Blob,
|
||||||
|
templateId?: string
|
||||||
|
): Promise<UploadVersionResult> {
|
||||||
|
void clientVersionId; // mock ไม่ใช้ — id ของ mock สร้างเอง
|
||||||
|
void fileName;
|
||||||
|
void templateId;
|
||||||
|
await delay(500); // ให้ข้อความ progress ของ flow ขึ้นจริง
|
||||||
|
|
||||||
|
const versions = store.get(documentId) ?? [];
|
||||||
|
const versionNumber = versions.reduce((max, item) => Math.max(max, item.record.versionNumber), 0) + 1;
|
||||||
|
const hasPdf = !!pdf && pdf.size > 0;
|
||||||
|
const trimmedMessage = message?.trim();
|
||||||
|
|
||||||
|
const record: VersionRecord = {
|
||||||
|
versionId: generateGuid(),
|
||||||
|
versionNumber,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
createdByUserId: authService.getUsername() ?? "unknown",
|
||||||
|
contentHash: await mockContentHash(file),
|
||||||
|
message: trimmedMessage || null,
|
||||||
|
hasPdf,
|
||||||
|
fields: fields && Object.keys(fields).length > 0 ? fields : null,
|
||||||
|
};
|
||||||
|
versions.push({ record, docxBlob: file, pdfBlob: pdf ?? null });
|
||||||
|
store.set(documentId, versions);
|
||||||
|
persist();
|
||||||
|
return { hasPdf, versionId: record.versionId, versionNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FR-6 — version history เรียงใหม่ → เก่า (คืนสำเนากัน caller mutate store ตรง ๆ) */
|
||||||
|
export async function getVersions(documentId: string): Promise<VersionRecord[]> {
|
||||||
|
await delay(200);
|
||||||
|
const versions = store.get(documentId) ?? [];
|
||||||
|
return versions
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((item) => ({ ...item.record }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VersionDownloadLink {
|
||||||
|
url: string;
|
||||||
|
/** ISO-8601 เวลาที่ URL หมดอายุ */
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FR-7 — ขอ presigned URL อายุสั้นของ version หนึ่ง เพื่อเปิดในเบราว์เซอร์ของผู้ใช้
|
||||||
|
* (webview ของ Word ทิ้ง blob download เงียบๆ — ให้เบราว์เซอร์จริงจัดการแทน)
|
||||||
|
* mock: ไม่มี URL จริงให้คืน — blob:/data: ใช้ผ่าน openBrowserWindow ไม่ได้ (blob ผูกกับ
|
||||||
|
* context ของ webview, data: โดนเบราว์เซอร์ block) ส่วน about:blank ที่คืนไปนี้ host จะ
|
||||||
|
* ปฏิเสธเงียบๆ เพราะ openBrowserWindow เปิดเฉพาะ http/https และเป็น void API ไม่รายงานกลับ
|
||||||
|
* — กดปุ่มดาวน์โหลด/PDF ตอน mock จึงไม่มีหน้าต่างเบราว์เซอร์เปิด (UI ยังเดินครบ flow)
|
||||||
|
*/
|
||||||
|
export async function getDownloadLink(
|
||||||
|
documentId: string,
|
||||||
|
versionId: string,
|
||||||
|
format?: "docx" | "pdf"
|
||||||
|
): Promise<VersionDownloadLink> {
|
||||||
|
void documentId;
|
||||||
|
void versionId;
|
||||||
|
void format;
|
||||||
|
await delay(150);
|
||||||
|
return { url: "about:blank", expiresAt: new Date(Date.now() + 10 * 60_000).toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** สร้าง clientVersionId ใหม่สำหรับ upload ครั้งถัดไป */
|
||||||
|
export function newClientVersionId(): string {
|
||||||
|
return generateGuid();
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue