phase 0
29
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# --- Node ---
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# --- add-in (webpack) ---
|
||||
addin/dist/
|
||||
|
||||
# --- web (Nuxt) ---
|
||||
web/.nuxt/
|
||||
web/.output/
|
||||
web/.data/
|
||||
web/dist/
|
||||
|
||||
# --- backend (.NET) ---
|
||||
backend/**/bin/
|
||||
backend/**/obj/
|
||||
*.user
|
||||
|
||||
# --- env & secrets (ใช้ .env.example เป็น template เท่านั้น) ---
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# --- OS / editor ---
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
.idea/
|
||||
76
CLAUDE.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## โปรเจกต์นี้คืออะไร
|
||||
|
||||
ระบบ **Word Versioning**: จัดการ version history ของไฟล์ .docx — สร้างเอกสารจาก template ผ่านเว็บ, ตรวจจับ tracked document ใน Word, บันทึก version ผ่านปุ่ม Save & Version และ sync ค่า fields กลับ DB ประกอบด้วย 3 ส่วน:
|
||||
|
||||
| ส่วน | Stack | หน้าที่ |
|
||||
|---|---|---|
|
||||
| `/addin` | Office.js + TypeScript + React + Fluent UI (`yo office`) | Task pane ใน Word Desktop/Online — detect, Save & Version, history, field extraction |
|
||||
| `/backend` | ASP.NET Core 8 + EF Core + PostgreSQL + MinIO (AWSSDK.S3) + OpenXML | Version API, template system, docx generation, JWT validation |
|
||||
| `/web` | Nuxt.js + TailwindCSS (light/dark theme) | จัดการ template + กรอก form สร้างเอกสารเป็น version 1 อัตโนมัติ |
|
||||
|
||||
Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`)
|
||||
|
||||
**สถานะปัจจุบัน: pre-implementation — ยังไม่มีโค้ด** งานคือ implement ตาม phase 0–8 ในแผน
|
||||
|
||||
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
|
||||
|
||||
- `specs/spec-officejs.md` — **source of truth ของ requirements** (FR-1–11, acceptance criteria 1–14, data models, platform constraints)
|
||||
- `docs/plans/officejs-implementation-plan.md` — implementation plan แบ่ง 9 phases พร้อม checklist, DoD, dependency ระหว่าง phase และตารา mapping Phase↔FR↔AC
|
||||
|
||||
เมื่อ requirement เปลี่ยน: แก้ spec ก่อน แล้ว sync ไปยัง plan
|
||||
|
||||
## กฎที่ห้ามละเมิด (ข้อจำกัดแพลตฟอร์มจริง — สรุปจาก spec ส่วน 2 และ 9)
|
||||
|
||||
1. **ห้ามออกแบบ flow ที่ดัก native save** (Ctrl+S / File>Save) — Office.js ไม่มี before-save event ที่ cancel ได้ ใช้ปุ่มใน task pane เท่านั้น
|
||||
2. **Custom Document Properties = primary flag** ตรวจจับ tracked document; **Custom XML Part = secondary เท่านั้น** (ต้อง delete แล้ว add ใหม่ทุกครั้ง, มี known bug บน Mac บาง build — ต้อง catch แบบไม่ throw)
|
||||
3. **ห้าม redirect iframe ของ task pane ไป Keycloak ตรงๆ** — ต้องใช้ Office Dialog API (`displayDialogAsync` + `messageParent`); ส่วน `/web` เป็น top-level page ใช้ OIDC redirect ปกติ
|
||||
4. **Token เก็บใน memory เท่านั้น** — ห้าม localStorage (theme preference ของ `/web` ยกเว้นได้)
|
||||
5. **Backend คือ source of truth** ของ version history และ field values
|
||||
6. **Field key = content control (SDT) tag** ใช้ค่าเดียวกันทั้ง generate (backend) / extract (add-in) / sync (DB)
|
||||
7. **Docx generation + metadata stamping ทำฝั่ง backend เท่านั้น** (OpenXML, แก้ที่ run level ใน SDT เพื่อรักษา formatting)
|
||||
8. **Ports:** add-in = `https://localhost:3000`, web = `http://localhost:5173` (ห้ามชนกัน), backend CORS ต้องอนุญาตทั้งคู่
|
||||
9. **Config (DB/MinIO/Keycloak) แยกเป็น env/appsettings เท่านั้น** — ห้าม hardcode; `userId` เอาจาก JWT claims เท่านั้น ห้ามรับจาก client
|
||||
10. **ทุก Office.js call ต้อง catch** — ห้ามปล่อย unhandled promise rejection
|
||||
|
||||
## สถาปัตยกรรม — flow หลักที่ต้องเข้าใจ
|
||||
|
||||
**Create → v1 (FR-10):** web form → `POST /api/documents/generate` → OpenXML แทนที่ค่าใน SDT + stamp `SavedByAddIn`/`DocumentTrackingId` ลงไฟล์ → สร้าง `Document` + `DocumentFieldValue` + upload v1 → ผู้ใช้โหลดไฟล์ไปเปิดใน Word → add-in ตรวจพบ tracked doc ทันทีเห็น v1
|
||||
|
||||
**Save & Version (FR-2):** ปุ่มใน task pane → stamp properties → อัปเดต XML part (delete-then-add, fail ได้แบบ silent) → `document.save()` → `getFileAsync` → POST version **พร้อม `fields` map จาก content controls (FR-11)** → backend update `DocumentFieldValue` (latest) + `FieldsSnapshotJson` (ต่อ version)
|
||||
|
||||
**Field sync policy (FR-11):** field ขาด (ผู้ใช้ลบ control) → เก็บค่าเดิม + mark missing **ไม่ fail request**; ส่งมาเกินนิยาม → ignore
|
||||
|
||||
**MinIO:** bucket `docx-versions` (snapshot ต่อ version, key `{documentId}/v{N:D4}_{clientVersionId}.docx`) + `docx-templates` (ไฟล์ template) — private ทั้งคู่, เข้าถึงผ่าน backend proxy stream เท่านั้น
|
||||
|
||||
**Template:** template.docx author ด้วย Word (ใส่ content controls) → upload → backend parse SDT tags → admin ตั้ง label/type/required → delete = deactivate (soft) เพราะ document เก่ายังอ้างอยู่
|
||||
|
||||
## คำสั่ง (ใช้ได้หลัง scaffold ใน Phase 0)
|
||||
|
||||
```bash
|
||||
docker compose up -d # infra: PostgreSQL + MinIO + Keycloak
|
||||
|
||||
# backend
|
||||
cd backend/WordVersioning.Api
|
||||
dotnet run # API + swagger
|
||||
dotnet ef migrations add <Name>
|
||||
dotnet test
|
||||
dotnet test --filter "FullyQualifiedName~GenerateTests" # รัน test เดี่ยว
|
||||
|
||||
# add-in (https://localhost:3000)
|
||||
cd addin
|
||||
npm run build # webpack build
|
||||
npm start # dev server — sideload ผ่าน manifest
|
||||
npm test
|
||||
npx jest src/services/metadataService.test.ts # รัน test เดี่ยว
|
||||
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว
|
||||
|
||||
# web portal (http://localhost:5173 — ห้ามใช้ 3000)
|
||||
cd web
|
||||
npm run dev
|
||||
npm test
|
||||
npx vitest run composables/useTheme.spec.ts # รัน test เดี่ยว
|
||||
```
|
||||
66
README.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Word Versioning Add-in
|
||||
|
||||
ระบบจัดการ version history ของเอกสาร Word — สร้างเอกสารจาก template ผ่านเว็บ ติดตามทุกการแก้ไขผ่าน add-in ใน Word และ sync ข้อมูลสำคัญกลับฐานข้อมูลอัตโนมัติ
|
||||
|
||||
## คุณสมบัติหลัก
|
||||
|
||||
- 📝 **สร้างเอกสารจาก template** — กรอก form บนเว็บ → ระบบ generate .docx จาก template (แทนที่ค่าใน content controls) พร้อม stamp metadata และขึ้นเป็น **version 1 อัตโนมัติ**
|
||||
- 🗂️ **Template management** — upload ไฟล์ .docx ที่มี content controls แล้วระบบ **detect fields อัตโนมัติ** ตั้งชื่อ/ชนิด/required ต่อ field ผ่านหน้าเว็บ
|
||||
- 📌 **ตรวจจับ tracked document** — เปิดไฟล์ใน Word (Desktop Win/Mac หรือ Online) แล้ว add-in รู้ทันทีว่าไฟล์นี้อยู่ในระบบ พร้อมแสดง version history
|
||||
- 💾 **Save & Version** — ปุ่มใน task pane สร้าง version ใหม่ (stamp metadata + อัปโหลด snapshot ไป backend)
|
||||
- 🔄 **Field sync** — ทุกครั้งที่สร้าง version ระบบ extract ค่าจาก content controls ของเอกสารไป update fields ใน DB ให้ sync กับเนื้อหาจริงเสมอ
|
||||
- ⬇️ **ดาวน์โหลด version ย้อนหลัง** — ทุก version เก็บครบพร้อม SHA256 hash ตรวจสอบความถูกต้องได้
|
||||
|
||||
## สถาปัตยกรรม
|
||||
|
||||
| ส่วน | Stack | หน้าที่ |
|
||||
|---|---|---|
|
||||
| **Add-in** (`/addin`) | Office.js, TypeScript, React, Fluent UI | Task pane ใน Word — ตรวจจับ document, Save & Version, version history, field extraction |
|
||||
| **Backend** (`/backend`) | ASP.NET Core 8, EF Core, PostgreSQL, MinIO, OpenXML | Version API, template system, docx generation, JWT validation |
|
||||
| **Web Portal** (`/web`) | Nuxt.js, TailwindCSS | จัดการ template + สร้างเอกสารจาก form (light/dark theme) |
|
||||
| **Auth** | Keycloak (OIDC + PKCE) | Public clients ทั้ง add-in และเว็บ, backend validate ผ่าน JWKS |
|
||||
| **Storage** | MinIO (S3-compatible) | bucket `docx-versions` (snapshot ต่อ version) + `docx-templates` (ไฟล์ template) — private ทั้งคู่ |
|
||||
|
||||
**Flow หลัก:**
|
||||
1. Admin upload template (.docx ที่มี content controls) → ระบบ detect fields อัตโนมัติ
|
||||
2. ผู้ใช้กรอก form บนเว็บ → ได้ไฟล์ .docx ที่เป็น version 1 แล้ว → เปิดใน Word
|
||||
3. Add-in รู้จักเอกสารทันที → แก้ไขเนื้อหา → กด **Save & Version** → เกิด version ใหม่ + fields ใน DB sync ตามเนื้อหาล่าสุด
|
||||
|
||||
## สถานะโปรเจกต์
|
||||
|
||||
> ⚠️ **อยู่ในขั้นตอนวางแผน — ยังไม่มีโค้ด** (spec และ implementation plan เสร็จแล้ว ยังไม่ได้เริ่ม implement)
|
||||
|
||||
## เอกสาร
|
||||
|
||||
- [specs/spec-officejs.md](specs/spec-officejs.md) — spec เต็มรูปแบบ: functional requirements (FR-1–11), data models, acceptance criteria (1–14)
|
||||
- [docs/plans/officejs-implementation-plan.md](docs/plans/officejs-implementation-plan.md) — implementation plan แบ่ง 9 phases พร้อม definition of done
|
||||
|
||||
สำหรับผู้เริ่มพัฒนา: อ่าน spec ก่อน แล้วตามด้วย plan (เริ่มจาก Phase 0)
|
||||
|
||||
## การพัฒนา (ใช้ได้หลัง implement Phase 0 ขึ้นไป)
|
||||
|
||||
```bash
|
||||
# 1) รัน infrastructure (PostgreSQL + MinIO + Keycloak)
|
||||
docker compose up -d
|
||||
|
||||
# 2) Backend
|
||||
cd backend/WordVersioning.Api
|
||||
dotnet run
|
||||
|
||||
# 3) Add-in dev server (https://localhost:3000 — sideload ใน Word ผ่าน manifest)
|
||||
cd addin
|
||||
npm start
|
||||
|
||||
# 4) Web Portal (http://localhost:5173)
|
||||
cd web
|
||||
npm run dev
|
||||
```
|
||||
|
||||
รายละเอียด local dev แบบเต็ม (sideload บน Word Desktop/Online, ตั้งค่า Keycloak/MinIO, สร้าง test user, สร้าง template แรก) จะเขียนใน README นี้อีกครั้งเมื่อ implement ถึง Phase 8 ของแผน
|
||||
|
||||
## เทคโนโลยี
|
||||
|
||||
- **Add-in**: Office.js (WordApi 1.5+), TypeScript, React, Fluent UI, webpack — scaffold ด้วย `yo office`
|
||||
- **Backend**: .NET 8, EF Core + Npgsql (PostgreSQL), AWSSDK.S3 → MinIO (`ForcePathStyle = true`), DocumentFormat.OpenXml, JwtBearer
|
||||
- **Web**: Nuxt.js (Vue 3), TailwindCSS (`darkMode: 'class'`)
|
||||
- **Auth**: Keycloak — realm `word-versioning`, clients: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto` (public + PKCE S256 ทุกตัว)
|
||||
8
addin/.eslintrc.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"plugins": [
|
||||
"office-addins"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:office-addins/react"
|
||||
]
|
||||
}
|
||||
8
addin/.hintrc
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": [
|
||||
"development"
|
||||
],
|
||||
"hints": {
|
||||
"typescript-config/strict": "off"
|
||||
}
|
||||
}
|
||||
14
addin/.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
// See http://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
|
||||
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
|
||||
|
||||
// List of extensions which should be recommended for users of this workspace.
|
||||
"recommendations": [
|
||||
"ms-edgedevtools.vscode-edge-devtools",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"msoffice.microsoft-office-add-in-debugger"
|
||||
],
|
||||
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
26
addin/.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Word Desktop (Edge Chromium)",
|
||||
"type": "msedge",
|
||||
"request": "attach",
|
||||
"port": 9229,
|
||||
"timeout": 600000,
|
||||
"webRoot": "${workspaceRoot}",
|
||||
"preLaunchTask": "Debug: Word Desktop",
|
||||
"postDebugTask": "Stop Debug"
|
||||
},
|
||||
{
|
||||
"name": "Word Desktop (Edge Legacy)",
|
||||
"type": "office-addin",
|
||||
"request": "attach",
|
||||
"url": "https://localhost:3000/taskpane.html?_host_Info=Word$Win32$16.01$en-US$$$$0",
|
||||
"port": 9222,
|
||||
"timeout": 600000,
|
||||
"webRoot": "${workspaceRoot}",
|
||||
"preLaunchTask": "Debug: Word Desktop",
|
||||
"postDebugTask": "Stop Debug"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
addin/.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript"
|
||||
]
|
||||
}
|
||||
|
||||
160
addin/.vscode/tasks.json
vendored
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Build (Development)",
|
||||
"type": "npm",
|
||||
"script": "build:dev",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"clear": true,
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Build (Production)",
|
||||
"type": "npm",
|
||||
"script": "build",
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"clear": true,
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Debug: Excel Desktop",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"start",
|
||||
"--",
|
||||
"desktop",
|
||||
"--app",
|
||||
"excel"
|
||||
],
|
||||
"presentation": {
|
||||
"clear": true,
|
||||
"panel": "dedicated"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Debug: Outlook Desktop",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"start",
|
||||
"--",
|
||||
"desktop",
|
||||
"--app",
|
||||
"outlook"
|
||||
],
|
||||
"presentation": {
|
||||
"clear": true,
|
||||
"panel": "dedicated"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Debug: PowerPoint Desktop",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"start",
|
||||
"--",
|
||||
"desktop",
|
||||
"--app",
|
||||
"powerpoint"
|
||||
],
|
||||
"presentation": {
|
||||
"clear": true,
|
||||
"panel": "dedicated"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Debug: Word Desktop",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"start",
|
||||
"--",
|
||||
"desktop",
|
||||
"--app",
|
||||
"word"
|
||||
],
|
||||
"presentation": {
|
||||
"clear": true,
|
||||
"panel": "dedicated"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Dev Server",
|
||||
"type": "npm",
|
||||
"script": "dev-server",
|
||||
"presentation": {
|
||||
"clear": true,
|
||||
"panel": "dedicated"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Install",
|
||||
"type": "npm",
|
||||
"script": "install",
|
||||
"presentation": {
|
||||
"clear": true,
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Lint: Check for problems",
|
||||
"type": "npm",
|
||||
"script": "lint",
|
||||
"problemMatcher": [
|
||||
"$eslint-stylish"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Lint: Fix all auto-fixable problems",
|
||||
"type": "npm",
|
||||
"script": "lint:fix",
|
||||
"problemMatcher": [
|
||||
"$eslint-stylish"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Stop Debug",
|
||||
"type": "npm",
|
||||
"script": "stop",
|
||||
"presentation": {
|
||||
"clear": true,
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Watch",
|
||||
"type": "npm",
|
||||
"script": "watch",
|
||||
"presentation": {
|
||||
"clear": true,
|
||||
"panel": "dedicated"
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
addin/assets/icon-128.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
addin/assets/icon-16.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
addin/assets/icon-32.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
addin/assets/icon-64.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
addin/assets/icon-80.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
addin/assets/logo-filled.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
13
addin/babel.config.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"targets": {
|
||||
"esmodules": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"@babel/preset-typescript"
|
||||
]
|
||||
}
|
||||
143
addin/manifest.json
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/teams/v1.17/MicrosoftTeams.schema.json",
|
||||
"id": "f2b103f1-1ab1-4e1b-8f0b-072aa3d4e19d",
|
||||
"manifestVersion": "1.17",
|
||||
"version": "1.0.0",
|
||||
"name": {
|
||||
"short": "Contoso Task Pane Add-in",
|
||||
"full": "Contoso Task Pane Add-in"
|
||||
},
|
||||
"description": {
|
||||
"short": "A template to get started.",
|
||||
"full": "This is the template to get started."
|
||||
},
|
||||
"developer": {
|
||||
"name": "Contoso",
|
||||
"websiteUrl": "https://www.contoso.com",
|
||||
"privacyUrl": "https://www.contoso.com/privacy",
|
||||
"termsOfUseUrl": "https://www.contoso.com/servicesagreement"
|
||||
},
|
||||
"icons": {
|
||||
"outline": "assets/outline.png",
|
||||
"color": "assets/color.png"
|
||||
},
|
||||
"accentColor": "#230201",
|
||||
"localizationInfo": {
|
||||
"defaultLanguageTag": "en-us",
|
||||
"additionalLanguages": []
|
||||
},
|
||||
"authorization": {
|
||||
"permissions": {
|
||||
"resourceSpecific": [
|
||||
{
|
||||
"name": "Mailbox.ReadWrite.User",
|
||||
"type": "Delegated"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"validDomains": ["contoso.com"],
|
||||
"extensions": [
|
||||
{
|
||||
"requirements": {
|
||||
"scopes": ["mail"],
|
||||
"capabilities": [
|
||||
{ "name": "Mailbox", "minVersion": "1.3" }
|
||||
]
|
||||
},
|
||||
"runtimes": [
|
||||
{
|
||||
"requirements": {
|
||||
"capabilities": [
|
||||
{ "name": "Mailbox", "minVersion": "1.3" }
|
||||
]
|
||||
},
|
||||
"id": "TaskPaneRuntime",
|
||||
"type": "general",
|
||||
"code": {
|
||||
"page": "https://localhost:3000/taskpane.html"
|
||||
},
|
||||
"lifetime": "short",
|
||||
"actions": [
|
||||
{
|
||||
"id": "TaskPaneRuntimeShow",
|
||||
"type":"openPage",
|
||||
"pinnable": false,
|
||||
"view": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "CommandsRuntime",
|
||||
"type": "general",
|
||||
"code": {
|
||||
"page": "https://localhost:3000/commands.html",
|
||||
"script": "https://localhost:3000/commands.js"
|
||||
},
|
||||
"lifetime": "short",
|
||||
"actions": [
|
||||
{
|
||||
"id": "action",
|
||||
"type": "executeFunction"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ribbons": [
|
||||
{
|
||||
"contexts": [
|
||||
"mailCompose"
|
||||
],
|
||||
"tabs": [
|
||||
{
|
||||
"builtInTabId": "TabDefault",
|
||||
"groups": [
|
||||
{
|
||||
"id": "msgComposeGroup",
|
||||
"label": "Contoso Add-in",
|
||||
"icons": [
|
||||
{ "size": 16, "url": "https://localhost:3000/assets/icon-16.png" },
|
||||
{ "size": 32, "url": "https://localhost:3000/assets/icon-32.png" },
|
||||
{ "size": 80, "url": "https://localhost:3000/assets/icon-80.png" }
|
||||
],
|
||||
"controls": [
|
||||
{
|
||||
"id": "msgComposeOpenPaneButton",
|
||||
"type": "button",
|
||||
"label": "Show Task Pane",
|
||||
"icons": [
|
||||
{ "size": 16, "url": "https://localhost:3000/assets/icon-16.png" },
|
||||
{ "size": 32, "url": "https://localhost:3000/assets/icon-32.png" },
|
||||
{ "size": 80, "url": "https://localhost:3000/assets/icon-80.png" }
|
||||
],
|
||||
"supertip": {
|
||||
"title": "Show Task Pane",
|
||||
"description": "Opens a task pane."
|
||||
},
|
||||
"actionId": "TaskPaneRuntimeShow"
|
||||
},
|
||||
{
|
||||
"id": "ActionButton",
|
||||
"type": "button",
|
||||
"label": "Perform an action",
|
||||
"icons": [
|
||||
{ "size": 16, "url": "https://localhost:3000/assets/icon-16.png" },
|
||||
{ "size": 32, "url": "https://localhost:3000/assets/icon-32.png" },
|
||||
{ "size": 80, "url": "https://localhost:3000/assets/icon-80.png" }
|
||||
],
|
||||
"supertip": {
|
||||
"title": "Perform an action",
|
||||
"description": "Perform an action when clicked."
|
||||
},
|
||||
"actionId": "action"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
85
addin/manifest.xml
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<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.0</Version>
|
||||
<ProviderName>Contoso</ProviderName>
|
||||
<DefaultLocale>en-US</DefaultLocale>
|
||||
<DisplayName DefaultValue="addin"/>
|
||||
<Description DefaultValue="A template to get started."/>
|
||||
<IconUrl DefaultValue="https://localhost:3000/assets/icon-32.png"/>
|
||||
<HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-64.png"/>
|
||||
<SupportUrl DefaultValue="https://www.contoso.com/help"/>
|
||||
<AppDomains>
|
||||
<AppDomain>https://www.contoso.com</AppDomain>
|
||||
</AppDomains>
|
||||
<Hosts>
|
||||
<Host Name="Document"/>
|
||||
</Hosts>
|
||||
<DefaultSettings>
|
||||
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
|
||||
</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/assets/icon-16.png"/>
|
||||
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/assets/icon-32.png"/>
|
||||
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/assets/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/commands.html"/>
|
||||
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
|
||||
</bt:Urls>
|
||||
<bt:ShortStrings>
|
||||
<bt:String id="GetStarted.Title" DefaultValue="Get started with your sample add-in!"/>
|
||||
<bt:String id="CommandsGroup.Label" DefaultValue="Commands Group"/>
|
||||
<bt:String id="TaskpaneButton.Label" DefaultValue="Show Task Pane"/>
|
||||
</bt:ShortStrings>
|
||||
<bt:LongStrings>
|
||||
<bt:String id="GetStarted.Description" DefaultValue="Your sample add-in loaded successfully. Go to the HOME tab and click the 'Show Task Pane' button to get started."/>
|
||||
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Click to Show a Task Pane"/>
|
||||
</bt:LongStrings>
|
||||
</Resources>
|
||||
</VersionOverrides>
|
||||
</OfficeApp>
|
||||
19178
addin/package-lock.json
generated
Normal file
76
addin/package.json
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
{
|
||||
"name": "office-addin-taskpane-react",
|
||||
"version": "0.0.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/OfficeDev/Office-Addin-TaskPane-React.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"config": {
|
||||
"app_to_debug": "word",
|
||||
"app_type_to_debug": "desktop",
|
||||
"dev_server_port": 3000
|
||||
},
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"build:dev": "webpack --mode development",
|
||||
"dev-server": "webpack serve --mode development",
|
||||
"lint": "office-addin-lint check",
|
||||
"lint:fix": "office-addin-lint fix",
|
||||
"prettier": "office-addin-lint prettier",
|
||||
"signin": "office-addin-dev-settings m365-account login",
|
||||
"signout": "office-addin-dev-settings m365-account logout",
|
||||
"start": "office-addin-debugging start manifest.xml",
|
||||
"stop": "office-addin-debugging stop manifest.xml",
|
||||
"validate": "office-addin-manifest validate manifest.xml",
|
||||
"watch": "webpack --mode development --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluentui/react-components": "^9.55.1",
|
||||
"@fluentui/react-icons": "^2.0.264",
|
||||
"core-js": "^3.36.0",
|
||||
"es6-promise": "^4.2.8",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"regenerator-runtime": "^0.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^8.0.1",
|
||||
"@babel/preset-env": "^8.0.2",
|
||||
"@babel/preset-typescript": "^8.0.1",
|
||||
"@types/office-js": "^1.0.377",
|
||||
"@types/office-runtime": "^1.0.35",
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"@types/webpack": "^5.28.5",
|
||||
"acorn": "^8.11.3",
|
||||
"babel-loader": "^10.1.1",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"eslint-plugin-office-addins": "^4.0.10",
|
||||
"eslint-plugin-react": "^7.28.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-loader": "^5.0.0",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"less": "^4.2.0",
|
||||
"less-loader": "^12.2.0",
|
||||
"office-addin-cli": "^2.0.10",
|
||||
"office-addin-debugging": "^6.1.2",
|
||||
"office-addin-dev-certs": "^2.0.10",
|
||||
"office-addin-lint": "^3.0.10",
|
||||
"office-addin-manifest": "^2.1.6",
|
||||
"office-addin-prettier-config": "^2.0.5",
|
||||
"os-browserify": "^0.3.0",
|
||||
"process": "^0.11.10",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"ts-loader": "^9.5.1",
|
||||
"typescript": "^5.4.2",
|
||||
"webpack": "^5.105.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^6.0.0"
|
||||
},
|
||||
"prettier": "office-addin-prettier-config",
|
||||
"browserslist": [
|
||||
"last 2 versions",
|
||||
"ie 11"
|
||||
]
|
||||
}
|
||||
19
addin/src/commands/commands.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
@@ -1,18 +0,0 @@
|
||||
<!-- Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT License. -->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
|
||||
<!-- Office JavaScript API -->
|
||||
<script type="text/javascript" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
35
addin/src/commands/commands.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
* See LICENSE in the project root for license information.
|
||||
*/
|
||||
|
||||
/* global Office */
|
||||
|
||||
Office.onReady(() => {
|
||||
// If needed, Office.js is ready to be called.
|
||||
});
|
||||
|
||||
/**
|
||||
* Shows a notification when the add-in command is executed.
|
||||
* @param event
|
||||
*/
|
||||
function action(event: Office.AddinCommands.Event) {
|
||||
const message: Office.NotificationMessageDetails = {
|
||||
type: Office.MailboxEnums.ItemNotificationMessageType.InformationalMessage,
|
||||
message: "Performed action.",
|
||||
icon: "Icon.80x80",
|
||||
persistent: true,
|
||||
};
|
||||
|
||||
// Show a notification message.
|
||||
Office.context.mailbox.item?.notificationMessages.replaceAsync(
|
||||
"ActionPerformanceNotification",
|
||||
message
|
||||
);
|
||||
|
||||
// Be sure to indicate when the add-in command function is complete.
|
||||
event.completed();
|
||||
}
|
||||
|
||||
// Register the function with Office.
|
||||
Office.actions.associate("action", action);
|
||||
47
addin/src/taskpane/components/App.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import * as React from "react";
|
||||
import Header from "./Header";
|
||||
import HeroList, { HeroListItem } from "./HeroList";
|
||||
import TextInsertion from "./TextInsertion";
|
||||
import { makeStyles } from "@fluentui/react-components";
|
||||
import { Ribbon24Regular, LockOpen24Regular, DesignIdeas24Regular } from "@fluentui/react-icons";
|
||||
import { insertText } from "../taskpane";
|
||||
|
||||
interface AppProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
minHeight: "100vh",
|
||||
},
|
||||
});
|
||||
|
||||
const App: React.FC<AppProps> = (props: AppProps) => {
|
||||
const styles = useStyles();
|
||||
// The list items are static and won't change at runtime,
|
||||
// so this should be an ordinary const, not a part of state.
|
||||
const listItems: HeroListItem[] = [
|
||||
{
|
||||
icon: <Ribbon24Regular />,
|
||||
primaryText: "Achieve more with Office integration",
|
||||
},
|
||||
{
|
||||
icon: <LockOpen24Regular />,
|
||||
primaryText: "Unlock features and functionality",
|
||||
},
|
||||
{
|
||||
icon: <DesignIdeas24Regular />,
|
||||
primaryText: "Create and visualize like a pro",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Header logo="assets/logo-filled.png" title={props.title} message="Welcome" />
|
||||
<HeroList message="Discover what this add-in can do for you today!" items={listItems} />
|
||||
<TextInsertion insertText={insertText} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
38
addin/src/taskpane/components/Header.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import * as React from "react";
|
||||
import { Image, tokens, makeStyles } from "@fluentui/react-components";
|
||||
|
||||
export interface HeaderProps {
|
||||
title: string;
|
||||
logo: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
welcome__header: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
paddingBottom: "30px",
|
||||
paddingTop: "100px",
|
||||
backgroundColor: tokens.colorNeutralBackground3,
|
||||
},
|
||||
message: {
|
||||
fontSize: tokens.fontSizeHero900,
|
||||
fontWeight: tokens.fontWeightRegular,
|
||||
fontColor: tokens.colorNeutralBackgroundStatic,
|
||||
},
|
||||
});
|
||||
|
||||
const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
|
||||
const { title, logo, message } = props;
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<section className={styles.welcome__header}>
|
||||
<Image width="90" height="90" src={logo} alt={title} />
|
||||
<h1 className={styles.message}>{message}</h1>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
62
addin/src/taskpane/components/HeroList.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import * as React from "react";
|
||||
import { tokens, makeStyles } from "@fluentui/react-components";
|
||||
|
||||
export interface HeroListItem {
|
||||
icon: React.JSX.Element;
|
||||
primaryText: string;
|
||||
}
|
||||
|
||||
export interface HeroListProps {
|
||||
message: string;
|
||||
items: HeroListItem[];
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
list: {
|
||||
marginTop: "20px",
|
||||
},
|
||||
listItem: {
|
||||
paddingBottom: "20px",
|
||||
display: "flex",
|
||||
},
|
||||
icon: {
|
||||
marginRight: "10px",
|
||||
},
|
||||
itemText: {
|
||||
fontSize: tokens.fontSizeBase300,
|
||||
fontColor: tokens.colorNeutralBackgroundStatic,
|
||||
},
|
||||
welcome__main: {
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
},
|
||||
message: {
|
||||
fontSize: tokens.fontSizeBase500,
|
||||
fontColor: tokens.colorNeutralBackgroundStatic,
|
||||
fontWeight: tokens.fontWeightRegular,
|
||||
paddingLeft: "10px",
|
||||
paddingRight: "10px",
|
||||
},
|
||||
});
|
||||
|
||||
const HeroList: React.FC<HeroListProps> = (props: HeroListProps) => {
|
||||
const { items, message } = props;
|
||||
const styles = useStyles();
|
||||
|
||||
const listItems = items.map((item, index) => (
|
||||
<li className={styles.listItem} key={index}>
|
||||
<i className={styles.icon}>{item.icon}</i>
|
||||
<span className={styles.itemText}>{item.primaryText}</span>
|
||||
</li>
|
||||
));
|
||||
return (
|
||||
<div className={styles.welcome__main}>
|
||||
<h2 className={styles.message}>{message}</h2>
|
||||
<ul className={styles.list}>{listItems}</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroList;
|
||||
57
addin/src/taskpane/components/TextInsertion.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { Button, Field, Textarea, tokens, makeStyles } from "@fluentui/react-components";
|
||||
|
||||
/* global HTMLTextAreaElement */
|
||||
|
||||
interface TextInsertionProps {
|
||||
insertText: (text: string) => void;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
instructions: {
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
marginTop: "20px",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
textPromptAndInsertion: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
},
|
||||
textAreaField: {
|
||||
marginLeft: "20px",
|
||||
marginTop: "30px",
|
||||
marginBottom: "20px",
|
||||
marginRight: "20px",
|
||||
maxWidth: "50%",
|
||||
},
|
||||
});
|
||||
|
||||
const TextInsertion: React.FC<TextInsertionProps> = (props: TextInsertionProps) => {
|
||||
const [text, setText] = useState<string>("Some text.");
|
||||
|
||||
const handleTextInsertion = async () => {
|
||||
await props.insertText(text);
|
||||
};
|
||||
|
||||
const handleTextChange = async (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setText(event.target.value);
|
||||
};
|
||||
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<div className={styles.textPromptAndInsertion}>
|
||||
<Field className={styles.textAreaField} size="large" label="Enter text to be inserted into the document.">
|
||||
<Textarea size="large" value={text} onChange={handleTextChange} />
|
||||
</Field>
|
||||
<Field className={styles.instructions}>Click the button to insert text.</Field>
|
||||
<Button appearance="primary" disabled={false} size="large" onClick={handleTextInsertion}>
|
||||
Insert text
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextInsertion;
|
||||
27
addin/src/taskpane/index.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import * as React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./components/App";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
|
||||
/* global document, Office, module, require, HTMLElement */
|
||||
|
||||
const title = "Contoso Task Pane Add-in";
|
||||
|
||||
const rootElement: HTMLElement | null = document.getElementById("container");
|
||||
const root = rootElement ? createRoot(rootElement) : undefined;
|
||||
|
||||
/* Render application after Office initializes */
|
||||
Office.onReady(() => {
|
||||
root?.render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<App title={title} />
|
||||
</FluentProvider>
|
||||
);
|
||||
});
|
||||
|
||||
if ((module as any).hot) {
|
||||
(module as any).hot.accept("./components/App", () => {
|
||||
const NextApp = require("./components/App").default;
|
||||
root?.render(NextApp);
|
||||
});
|
||||
}
|
||||
21
addin/src/taskpane/taskpane.html
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!-- Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -->
|
||||
<!-- See LICENSE in the project root for license information -->
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en" data-framework="typescript">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Contoso Task Pane Add-in</title>
|
||||
|
||||
<!-- Office JavaScript API -->
|
||||
<script type="text/javascript" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
</head>
|
||||
|
||||
<body style="width: 100%; height: 100%; margin: 0; padding: 0;">
|
||||
<div id="container"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
14
addin/src/taskpane/taskpane.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/* global Word console */
|
||||
|
||||
export async function insertText(text: string) {
|
||||
// Write text to the document.
|
||||
try {
|
||||
await Word.run(async (context) => {
|
||||
let body = context.document.body;
|
||||
body.insertParagraph(text, Word.InsertLocation.end);
|
||||
await context.sync();
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error: " + error);
|
||||
}
|
||||
}
|
||||
29
addin/tsconfig.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"jsx": "react",
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedParameters": true,
|
||||
"outDir": "dist",
|
||||
"sourceMap": true,
|
||||
"target": "es6",
|
||||
"lib": [
|
||||
"es7",
|
||||
"dom"
|
||||
],
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"ts-node": {
|
||||
"compilerOptions": {
|
||||
"module": "commonjs"
|
||||
},
|
||||
"files": true
|
||||
}
|
||||
}
|
||||
111
addin/webpack.config.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/* eslint-disable no-undef */
|
||||
|
||||
const devCerts = require("office-addin-dev-certs");
|
||||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||
const webpack = require("webpack");
|
||||
|
||||
const urlDev = "https://localhost:3000/";
|
||||
const urlProd = "https://www.contoso.com/"; // CHANGE THIS TO YOUR PRODUCTION DEPLOYMENT LOCATION
|
||||
|
||||
async function getHttpsOptions() {
|
||||
const httpsOptions = await devCerts.getHttpsServerOptions();
|
||||
return { ca: httpsOptions.ca, key: httpsOptions.key, cert: httpsOptions.cert };
|
||||
}
|
||||
|
||||
module.exports = async (env, options) => {
|
||||
const dev = options.mode === "development";
|
||||
const config = {
|
||||
devtool: "source-map",
|
||||
entry: {
|
||||
polyfill: ["core-js/stable", "regenerator-runtime/runtime"],
|
||||
react: ["react", "react-dom"],
|
||||
taskpane: {
|
||||
import: ["./src/taskpane/index.tsx", "./src/taskpane/taskpane.html"],
|
||||
dependOn: "react",
|
||||
},
|
||||
commands: "./src/commands/commands.ts",
|
||||
},
|
||||
output: {
|
||||
clean: true,
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".ts", ".tsx", ".html", ".js"],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.ts$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: "babel-loader",
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
exclude: /node_modules/,
|
||||
use: ["ts-loader"],
|
||||
},
|
||||
{
|
||||
test: /\.html$/,
|
||||
exclude: /node_modules/,
|
||||
use: "html-loader",
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpg|jpeg|ttf|woff|woff2|gif|ico)$/,
|
||||
type: "asset/resource",
|
||||
generator: {
|
||||
filename: "assets/[name][ext][query]",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
filename: "taskpane.html",
|
||||
template: "./src/taskpane/taskpane.html",
|
||||
chunks: ["polyfill", "taskpane", "react"],
|
||||
}),
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: "assets/*",
|
||||
to: "assets/[name][ext][query]",
|
||||
},
|
||||
{
|
||||
from: "manifest*.xml",
|
||||
to: "[name]" + "[ext]",
|
||||
transform(content) {
|
||||
if (dev) {
|
||||
return content;
|
||||
} else {
|
||||
return content.toString().replace(new RegExp(urlDev, "g"), urlProd);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
filename: "commands.html",
|
||||
template: "./src/commands/commands.html",
|
||||
chunks: ["polyfill", "commands"],
|
||||
}),
|
||||
new webpack.ProvidePlugin({
|
||||
Promise: ["es6-promise", "Promise"],
|
||||
}),
|
||||
],
|
||||
devServer: {
|
||||
hot: true,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
server: {
|
||||
type: "https",
|
||||
options: env.WEBPACK_BUILD || options.https !== undefined ? options.https : await getHttpsOptions(),
|
||||
},
|
||||
port: process.env.npm_package_config_dev_server_port || 3000,
|
||||
},
|
||||
};
|
||||
|
||||
return config;
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace WordVersioning.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
public class WeatherForecastController : ControllerBase
|
||||
{
|
||||
private static readonly string[] Summaries = new[]
|
||||
{
|
||||
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
||||
};
|
||||
|
||||
private readonly ILogger<WeatherForecastController> _logger;
|
||||
|
||||
public WeatherForecastController(ILogger<WeatherForecastController> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet(Name = "GetWeatherForecast")]
|
||||
public IEnumerable<WeatherForecast> Get()
|
||||
{
|
||||
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
|
||||
{
|
||||
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
|
||||
TemperatureC = Random.Shared.Next(-20, 55),
|
||||
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
25
backend/WordVersioning.Api/Program.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
|
||||
builder.Services.AddControllers();
|
||||
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
41
backend/WordVersioning.Api/Properties/launchSettings.json
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:12089",
|
||||
"sslPort": 44362
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5075",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "https://localhost:7232;http://localhost:5075",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
backend/WordVersioning.Api/WeatherForecast.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
namespace WordVersioning.Api;
|
||||
|
||||
public class WeatherForecast
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
|
||||
public int TemperatureC { get; set; }
|
||||
|
||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||
|
||||
public string? Summary { get; set; }
|
||||
}
|
||||
21
backend/WordVersioning.Api/WordVersioning.Api.csproj
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AWSSDK.S3" Version="4.0.102.1" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
6
backend/WordVersioning.Api/WordVersioning.Api.http
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
@WordVersioning.Api_HostAddress = http://localhost:5075
|
||||
|
||||
GET {{WordVersioning.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
8
backend/WordVersioning.Api/appsettings.Development.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
backend/WordVersioning.Api/appsettings.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
438
docs/plans/officejs-implementation-plan.md
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
|
||||
|
||||
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
|
||||
> สถานะ: รอเริ่ม implement (repo ยังไม่มีโค้ด)
|
||||
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
|
||||
|
||||
---
|
||||
|
||||
## 1. ภาพรวม
|
||||
|
||||
ระบบ 3 ส่วนที่ทำงานร่วมกัน:
|
||||
|
||||
| ส่วน | เทคโนโลยี | หน้าที่ |
|
||||
|---|---|---|
|
||||
| **Add-in** (`/addin`) | Office.js, TypeScript, React, Fluent UI, webpack (scaffold จาก `yo office`) | Task pane ใน Word Desktop (Win/Mac) + Word Online — ตรวจจับ tracked document, ปุ่ม Save & Version, แสดง version history, จัดการ auth, extract fields จากเอกสาร |
|
||||
| **Backend** (`/backend/WordVersioning.Api`) | ASP.NET Core 8, EF Core, **PostgreSQL**, AWSSDK.S3 → **MinIO**, DocumentFormat.OpenXml | เก็บ version metadata + ไฟล์ .docx ใน MinIO, validate JWT, ระบบ template + generate เอกสาร + field sync |
|
||||
| **Web Portal** (`/web`) | **Nuxt.js + TailwindCSS** (รองรับ light/dark theme) | เว็บแยกสำหรับ (1) จัดการ template (2) กรอก form สร้างเอกสารจาก template → ได้ .docx ที่เป็น **version 1** พร้อม metadata แล้ว |
|
||||
| **Auth** | **Keycloak** (OIDC) | Public clients + PKCE (ทั้ง add-in และเว็บ), backend validate ผ่าน JWKS |
|
||||
|
||||
### ข้อตกลงที่ยืนยันแล้ว
|
||||
- **Database = PostgreSQL** (เลือกจาก spec ที่เปิดไว้ให้เลือก SQL Server หรือ PostgreSQL) — ใช้ provider `Npgsql.EntityFrameworkCore.PostgreSQL`
|
||||
- **Scope** = Office.js add-in + .NET backend + Web Portal ตามเอกสารนี้ (spec มีอ้างถึง `spec-vsto.md` แต่ไฟล์ยังไม่มี — แผนนี้เพียง pre-register Keycloak client `word-addin-vsto` ไว้ตามที่ spec กำหนด ไม่มีงาน VSTO)
|
||||
- หน้าจอสร้างเอกสารครั้งแรก = **เว็บแยก (standalone)** — กรอกนอก Word → ดาวน์โหลด .docx ที่ backend stamp metadata + สร้าง v1 ไว้แล้ว → เปิดใน Word แล้ว add-in รู้จักทันที
|
||||
- Template = **หลาย template + หน้าจัดการเต็มรูปแบบ** (CRUD + นิยาม field ต่อ template)
|
||||
- Field sync = **เฉพาะ fields จาก template** (ผูกกับ content controls) ไม่เก็บ full text
|
||||
- **FR-9 ถึง FR-11 เป็น requirement ที่เพิ่มภายหลัง spec เดิม** — spec ได้รับการ update เป็นฉบับ v2 แล้ว (ครอบ FR-1–11 + AC-1–14) ให้ถือ spec เป็น source of truth ของ requirements
|
||||
|
||||
---
|
||||
|
||||
## 2. กติกาพื้นฐานที่ทุก phase ต้องยึด
|
||||
|
||||
กติกาเหล่านี้เป็นข้อจำกัดของแพลตฟอร์ม ห้ามละเมิดไม่ว่าจะ implement phase ไหน:
|
||||
|
||||
1. **ห้ามออกแบบ flow ที่พึ่งการดัก native save** (Ctrl+S / File>Save) — Office.js ไม่มี before-save event ที่ cancel ได้ ต้องใช้ explicit action (ปุ่มใน task pane / ribbon) เท่านั้น
|
||||
2. **Custom Document Properties = primary flag** สำหรับตรวจจับว่าไฟล์ผ่าน add-in นี้
|
||||
3. **Custom XML Part = secondary storage เท่านั้น** — แก้ไม่ได้ตรงๆ ต้อง `delete()` แล้ว `add()` ใหม่ทุกครั้ง และมี known bug ที่อาจไม่ persist บน Mac บาง build → ระบบต้องทำงานถูกต้องแม้ XML part ล้มเหลวแบบ silent
|
||||
4. **ห้าม redirect iframe ของ task pane ไป Keycloak ตรงๆ** (โดน `X-Frame-Options`/CSP บล็อก) — ต้องใช้ Office Dialog API (`displayDialogAsync`) + `messageParent`; ส่วน `/web` เป็น top-level page ใช้ OIDC redirect ปกติได้
|
||||
5. **Token เก็บใน memory เท่านั้น** — ห้าม localStorage; ถ้าจำเป็นต้อง persist ใช้ `sessionStorage` อายุสั้น (ข้อยกเว้น: theme preference ของ `/web` เก็บ localStorage ได้เพราะไม่ใช่ข้อมูลรับรอง)
|
||||
6. **Backend คือ source of truth** ของ version history และ field values เสมอ
|
||||
7. **Config ทุกอย่าง (MinIO, Keycloak, DB) แยกเป็น env/`appsettings.json`** ห้าม hardcode
|
||||
8. **ทุก Office.js call ต้อง catch** และแสดง error ที่เข้าใจง่ายใน task pane — ห้ามปล่อย unhandled promise rejection
|
||||
9. **Field key = content control (SDT) tag** ต้อง unique ต่อ template — ใช้ tag เดียวกันทั้งตอน generate (backend), ตอน extract (add-in) และตอน sync (DB)
|
||||
10. **Generation + metadata stamping ทำฝั่ง backend เท่านั้น** (OpenXML SDK) เพื่อให้ v1 + custom properties เกิดพร้อมกันแบบ atomic — ไฟล์ที่โหลดจากเว็บถูกต้องสมบูรณ์ตั้งแต่ครั้งแรก ไม่ต้องพึ่ง client ตั้งค่าใดๆ
|
||||
11. **⚠️ Port ห้ามชนกัน:** add-in dev server = `https://localhost:3000` (ตาม yo office) → Nuxt dev server ต้องตั้ง **port 5173** ใน `nuxt.config.ts` ห้ามใช้ default 3000
|
||||
|
||||
---
|
||||
|
||||
## 3. โครงสร้างไฟล์เป้าหมาย
|
||||
|
||||
```
|
||||
/word-addin
|
||||
/addin # Phase 0, 4, 5
|
||||
manifest.xml
|
||||
/src
|
||||
/taskpane
|
||||
taskpane.html
|
||||
taskpane.tsx
|
||||
auth-callback.html
|
||||
/components
|
||||
VersionHistoryPanel.tsx
|
||||
ConfirmVersionDialog.tsx
|
||||
/services
|
||||
metadataService.ts # custom properties + custom XML part + content controls
|
||||
versioningService.ts
|
||||
fileService.ts
|
||||
authService.ts
|
||||
apiClient.ts
|
||||
/models
|
||||
versionRecord.ts
|
||||
package.json
|
||||
webpack.config.js
|
||||
/backend # Phase 0, 2, 3
|
||||
/WordVersioning.Api
|
||||
Program.cs
|
||||
/Controllers
|
||||
DocumentsController.cs # FR-5–7 + generate endpoint + field sync
|
||||
TemplatesController.cs # FR-9
|
||||
/Models
|
||||
Document.cs # + TemplateId
|
||||
DocumentVersion.cs # + FieldsSnapshotJson
|
||||
Template.cs
|
||||
TemplateField.cs
|
||||
DocumentFieldValue.cs
|
||||
/Data
|
||||
AppDbContext.cs
|
||||
/Services
|
||||
ObjectStorageService.cs
|
||||
HashService.cs
|
||||
DocxGenerationService.cs # FR-10: แทนที่ SDT + stamp custom properties
|
||||
appsettings.json
|
||||
WordVersioning.Api.csproj
|
||||
/web # Phase 0, 6
|
||||
nuxt.config.ts # devServer port 5173
|
||||
tailwind.config.ts # darkMode: 'class'
|
||||
/pages
|
||||
index.vue # หน้าหลัก: เลือก template เพื่อสร้างเอกสาร
|
||||
templates/index.vue # รายการ template (admin)
|
||||
templates/new.vue # upload template + ตั้งค่า field defs
|
||||
templates/[id]/edit.vue # แก้ไข template + field defs
|
||||
documents/create/[templateId].vue # dynamic form สร้างเอกสาร (FR-10)
|
||||
/composables
|
||||
useAuth.ts # OIDC PKCE + browser redirect ปกติ
|
||||
useApi.ts # fetch wrapper + Bearer token + refresh
|
||||
useTheme.ts # light/dark toggle + persist + system default
|
||||
/assets/css/tailwind.css
|
||||
package.json
|
||||
/keycloak # Phase 1
|
||||
realm-export.json
|
||||
docker-compose.yml # Phase 1
|
||||
README.md # Phase 8
|
||||
/specs /docs # มีอยู่แล้ว
|
||||
```
|
||||
|
||||
**ลำดับ dependency:** `0 → 1 → 2 → 3` เป็นเส้นหลัก จากนั้นแยก 2 สายทำขนานกันได้: `3 → 4 → 5` (add-in) และ `3 → 6` (web); **Phase 7 (tests)** ทำขนานกับ 4–6 ได้; **Phase 8** ปิดท้าย
|
||||
|
||||
---
|
||||
|
||||
## Functional Requirements เพิ่มเติม (นอก spec เดิม)
|
||||
|
||||
### FR-9: Template management (backend + web)
|
||||
- CRUD template: upload ไฟล์ .docx (มี content controls), ตั้ง name/description/active flag
|
||||
- ตอน upload/replaces ไฟล์: backend **parse content controls (SDT tags) ออกมาเป็น field list อัตโนมัติ** ด้วย OpenXML
|
||||
- Admin ตั้งค่าต่อ field: label, type (`text` / `number` / `date`), required, ลำดับ — เก็บเป็น `TemplateField`
|
||||
- ลบ template = **soft-delete (deactivate) เท่านั้น** เพราะ document เก่ายังอ้างอยู่; เอกสารที่สร้างแล้วใช้ template ที่ deactivate ได้ต่อเนื่อง
|
||||
- ไฟล์ template เก็บใน MinIO bucket `docx-templates` (private)
|
||||
|
||||
### FR-10: สร้างเอกสารจาก template ผ่านเว็บ
|
||||
```
|
||||
POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
|
||||
```
|
||||
- Validate: required fields ครบ, type ถูก (number/date parse ได้)
|
||||
- Backend `DocxGenerationService`: โหลด template จาก MinIO → **แทนที่ข้อความใน SDT แต่ละ field (แก้ที่ run level เพื่อรักษา formatting)** → **stamp Custom Properties** (`SavedByAddIn` = ADDIN_SIGNATURE_GUID, `DocumentTrackingId` = GUID ใหม่) ลงในไฟล์ด้วย OpenXML
|
||||
- สร้าง `Document` (ผูก `TemplateId`) + `DocumentFieldValue` ทุก field + **upload เป็น version 1** + snapshot fields ของ v1
|
||||
- Response: `{ documentId, versionId, versionNumber, downloadUrl }` → ผู้ใช้ดาวน์โหลด .docx ไปเปิดใน Word → **FR-1 เดิมทำงานทันที** (add-in ตรวจพบ tracked document เห็น v1 โดยไม่ต้องกด Start Versioning)
|
||||
|
||||
### FR-11: Field sync จาก add-in → DB
|
||||
- ตอน Save & Version: add-in อ่าน `context.document.contentControls` ทั้งหมด → ส่ง map `{ tag: text }` แนบกับ `POST .../versions`
|
||||
- Backend เทียบกับ `TemplateField` ของ template ที่ document ผูกอยู่ → update **latest value ต่อ field** (`DocumentFieldValue`) + เก็บ **snapshot ต่อ version** (`DocumentVersion.FieldsSnapshotJson` — ใช้ diff ใน history ได้)
|
||||
- field ที่หาไม่เจอในเอกสาร (ผู้ใช้ลบ content control ไป) → **เก็บค่าเดิมไว้ + mark missing** ใน snapshot แต่**ไม่ fail ทั้ง flow**
|
||||
- การ sync ผูกกับ JWT ของผู้กด Save & Version (`UpdatedByUserId` จาก claims ตาม FR-8 เดิม)
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Scaffold โครงสร้างโปรเจกต์
|
||||
|
||||
**เป้า:** ได้โครงที่ build/run ได้ทั้งสามส่วน ก่อนใส่ logic จริง
|
||||
|
||||
- [x] ตรวจ prerequisites: Node LTS + Yeoman + `generator-office`, .NET 8 SDK, Docker
|
||||
- [x] `yo office` เลือก task pane, TypeScript, React → ย้าย output มาไว้ที่ `/addin`
|
||||
- [x] `dotnet new webapi -n WordVersioning.Api` → `/backend/WordVersioning.Api`
|
||||
- [x] เพิ่ม packages: `Npgsql.EntityFrameworkCore.PostgreSQL`, `Microsoft.EntityFrameworkCore.Design`, `AWSSDK.S3`, `Microsoft.AspNetCore.Authentication.JwtBearer`, `DocumentFormat.OpenXml`
|
||||
- [x] Scaffold `/web`: Nuxt.js + TailwindCSS — ตั้ง **`devServer.port = 5173`** ใน `nuxt.config.ts` (ห้ามชน add-in ที่ 3000), `darkMode: 'class'` ใน tailwind config, โครง `useTheme.ts` (toggle + persist localStorage + default ตาม `prefers-color-scheme`)
|
||||
- [x] สร้าง `.gitignore` (root) ครอบทั้ง node_modules/bin/obj/.nuxt/env แล้ว `git init`
|
||||
- [x] ปรับ `manifest.xml` ฐานจาก yo office ให้ dev server ชี้ `https://localhost:3000`
|
||||
|
||||
หมายเหตุจากการทำจริง (2026-08-18):
|
||||
- คำสั่ง scaffold: `npx yo office react addin word --ts` (generator-office ปัจจุบันใช้ projectType `react` แทน flag `--framework react`)
|
||||
- .NET packages ที่รองรับ net8.0 ต้องตรึง `--version 8.0.11` (Npgsql.EFCore / EFCore.Design / JwtBearer) เพราะ latest (10.x) รองรับเฉพาะ net10.0; AWSSDK.S3 4.0.102.1 + DocumentFormat.OpenXml 3.5.1 ใช้ latest ได้
|
||||
- Nuxt เวอร์ชันที่ได้ = 4.5.2 → โครง `app/` directory (`app/pages`, `app/composables`, `app/assets/css`) แทน root-level ตามแผนเดิม — ความหมายเดียวกัน
|
||||
- ทดสอบ DoD ผ่านทั้งสามส่วน (webpack compiled successfully / Build succeeded 0 errors / หน้าแรก render ที่ 5173 พร้อม dark: variants แบบ class strategy)
|
||||
|
||||
**Deliverable:** โครง 3 โปรเจกต์ตาม section 3
|
||||
|
||||
**DoD:** `npm run build` ใน `/addin` ผ่าน + `dotnet build` ใน `/backend` ผ่าน + `npm run dev` ใน `/web` เปิดหน้าแรกได้ที่ `http://localhost:5173` พร้อม toggle light/dark ทำงาน
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Local dev infrastructure (docker-compose + Keycloak + MinIO)
|
||||
|
||||
**เป้า:** รัน infra ทั้งชุดด้วยคำสั่งเดียว — ตรง spec ส่วน 11 (backend ข้อ 6, 8)
|
||||
|
||||
- [ ] `docker-compose.yml` กำหนด services:
|
||||
- `db` — `postgres:16` (volume สำหรับข้อมูล)
|
||||
- `minio` — `minio/minio` (console เปิดด้วย) + init service สร้าง bucket **`docx-versions`** และ **`docx-templates`** แบบ **private** ทั้งคู่
|
||||
- `keycloak` — `quay.io/keycloak/keycloak` start-dev + import realm อัตโนมัติ
|
||||
- `api` — build จาก `/backend` (รอ Phase 2 เสร็จค่อย enable ก็ได้)
|
||||
- [ ] `/keycloak/realm-export.json`:
|
||||
- realm `word-versioning`
|
||||
- client `word-addin-officejs` — public, Standard Flow, **PKCE S256 บังคับ**, ไม่มี secret, redirect URI `https://localhost:3000/auth-callback.html`
|
||||
- client `word-versioning-web` — public, Standard Flow, PKCE S256, redirect URI `http://localhost:5173/*` (web ทุก route เพราะใช้ redirect ปกติ)
|
||||
- client `word-addin-vsto` — public + PKCE (pre-register ตาม spec)
|
||||
- สร้าง test user แรกไว้ใน realm export เลยก็ได้
|
||||
- [ ] กำหนด env defaults (เช่น ใน `.env.example`): MinIO endpoint/keys, Keycloak admin password, DB password
|
||||
|
||||
**DoD:** `docker compose up` แล้ว (1) เข้า Keycloak admin console เห็น realm `word-versioning` + clients ครบ 3 ตัว (2) เข้า MinIO console เห็น bucket `docx-versions` + `docx-templates` ที่เป็น private
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Backend core — FR-5, FR-6, FR-7, FR-8 (ฝั่ง server)
|
||||
|
||||
**เป้า:** API ครบทุก endpoint ของ spec เดิม พร้อม auth + storage จริง — ตรง spec ส่วน 11 (backend ข้อ 1–5)
|
||||
|
||||
### 2.1 Data layer
|
||||
- [ ] `Models/Document.cs`, `Models/DocumentVersion.cs` ตาม spec ส่วน 7 + เพิ่ม `TemplateId` (Document) และ `FieldsSnapshotJson` (DocumentVersion) สำหรับ FR-10/11
|
||||
- [ ] `Data/AppDbContext.cs` (Npgsql) + unique index `(DocumentId, VersionNumber)`
|
||||
- [ ] EF migration แรก
|
||||
|
||||
### 2.2 Services
|
||||
- [ ] `IObjectStorageService` + `ObjectStorageService` — `AmazonS3Client` ตั้ง `ServiceURL` = MinIO endpoint, **`ForcePathStyle = true`** (จำเป็นสำหรับ MinIO), methods: `PutAsync(bucket, key, stream, contentType)`, `GetStreamAsync(bucket, key)`, config (`Endpoint`, `AccessKey`, `SecretKey`, `UseSSL`) จาก `appsettings.json`/env; `BucketName` แยกเป็น `VersionsBucket` + `TemplatesBucket`
|
||||
- [ ] `HashService` — SHA256 คำนวณตอน stream ผ่าน (ไม่ต้อง load ทั้งไฟล์ใน memory ถ้าไหว)
|
||||
|
||||
### 2.3 Auth (FR-8 ฝั่ง backend)
|
||||
- [ ] `AddAuthentication(JwtBearer)` config `Authority` = Keycloak realm URL, `Audience` ครอบทั้ง client ของ add-in และ web (หรือใช้ realm-level validation ตามที่ config ได้), `RequireHttpsMetadata = false` (dev เท่านั้น — คอมเมนต์ไว้ชัดว่า prod ต้อง true)
|
||||
- [ ] `[Authorize]` บน `DocumentsController` และ `TemplatesController` ทั้ง controller
|
||||
- [ ] Helper ดึง userId/username จาก claims `sub` / `preferred_username` — **ห้ามรับค่านี้จาก client**
|
||||
|
||||
### 2.4 Endpoints (`DocumentsController`)
|
||||
- [ ] `POST /api/documents/{documentId}/versions` (FR-5) — multipart: `file` + `clientVersionId` + **`fields` (JSON map tag→text, เตรียมทางไว้ให้ FR-11)**; auto-create `Document` ถ้ายังไม่มี; versionNumber = max+1 ต่อ document (มี unique index รองรับ concurrency); object key `{documentId}/v{versionNumber:D4}_{clientVersionId}.docx`; คำนวณ + เก็บ SHA256; response `{ versionId, versionNumber }`
|
||||
- [ ] `GET /api/documents/{documentId}/versions` (FR-6) — เรียงใหม่ → เก่า (คืน snapshot fields ต่อ version ด้วย)
|
||||
- [ ] `GET /api/documents/{documentId}/versions/{versionId}/download` (FR-7) — **proxy stream** จาก MinIO (ไม่ expose MinIO ตรงๆ) พร้อม content-type ที่ถูกต้อง
|
||||
- [ ] Validation: reject content-type ที่ไม่ใช่ docx, จำกัด request size **100MB** (`RequestSizeLimit` / Kestrel config)
|
||||
|
||||
### 2.5 Cross-cutting
|
||||
- [ ] CORS อนุญาต **`https://localhost:3000` (add-in) และ `http://localhost:5173` (web)**
|
||||
- [ ] Logging ทุก upload/download/generate: documentId, versionId, userId
|
||||
- [ ] `appsettings.json` + env overrides ครบทุก config (DB, MinIO, Keycloak)
|
||||
|
||||
**DoD:** `dotnet run` + Swagger: (1) เรียก endpoint ไหนก็ได้โดยไม่แนบ token → **401** (2) ขอ token จาก Keycloak ด้วย test user แล้ว upload ไฟล์ docx จริง → มี record ใน Postgres + object ใน MinIO + download กลับมาเปิดได้ (AC-3 ผ่าน)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Backend template & generation — FR-9, FR-10 (ใหม่)
|
||||
|
||||
**เป้า:** ระบบ template ครบวงจร — upload พร้อม auto-detect fields + generate เอกสาร v1 พร้อม metadata
|
||||
|
||||
### 3.1 Entities + migrations
|
||||
- [ ] `Template` — Id (Guid), Name, Description, ObjectKey (ใน bucket `docx-templates`), IsActive (soft-delete), CreatedAt/UpdatedAt
|
||||
- [ ] `TemplateField` — Id, TemplateId, Key (= SDT tag), Label, Type (`text|number|date`), IsRequired, SortOrder; unique `(TemplateId, Key)`
|
||||
- [ ] `DocumentFieldValue` — DocumentId, FieldKey, Value (string), UpdatedAt, UpdatedByUserId, SourceVersionId; unique `(DocumentId, FieldKey)` = latest value ต่อ field
|
||||
- [ ] `Document` เพิ่ม `TemplateId?` + migration
|
||||
|
||||
### 3.2 TemplatesController (FR-9)
|
||||
- [ ] `GET /api/templates` — list (filter active/all)
|
||||
- [ ] `POST /api/templates` — multipart: ไฟล์ .docx + name/description → upload ลง MinIO + **parse SDT tags คืนเป็น field list ที่ detect ได้** (response ให้ admin ไปตั้ง label/type/required ต่อ)
|
||||
- [ ] `PUT /api/templates/{id}` — แก้ metadata + บันทึก field definitions
|
||||
- [ ] `PUT /api/templates/{id}/file` — แทนที่ไฟล์ (re-parse fields, เตือนถ้า tag หายไป)
|
||||
- [ ] `DELETE /api/templates/{id}` = deactivate (soft-delete)
|
||||
|
||||
### 3.3 DocxGenerationService (FR-10)
|
||||
- [ ] โหลด template จาก MinIO → เปิดด้วย `DocumentFormat.OpenXml`
|
||||
- [ ] แทนที่ข้อความใน SDT แต่ละ field **ที่ run level** (รักษา run properties/formatting ของ template — ไม่ทับทั้ง paragraph)
|
||||
- [ ] Stamp Custom Properties ผ่าน `CustomFilePropertiesPart`: `SavedByAddIn` = `ADDIN_SIGNATURE_GUID` (ค่าคงที่เดียวกับที่ add-in ใช้ตรวจ), `DocumentTrackingId` = Guid ใหม่
|
||||
- [ ] `POST /api/documents/generate { templateId, values }`:
|
||||
1. Validate required + type
|
||||
2. Generate ไฟล์จาก template
|
||||
3. คำนวณ SHA256 + upload เป็น **version 1** (`ObjectStorageService`)
|
||||
4. สร้าง `Document` (TemplateId, FileName จาก template name + timestamp) + `DocumentFieldValue` ทุก field + `DocumentVersion.FieldsSnapshotJson` ของ v1
|
||||
5. Response `{ documentId, versionId, versionNumber, downloadUrl }`
|
||||
|
||||
### 3.4 Field sync บน upload endpoint (FR-11 ฝั่ง backend)
|
||||
- [ ] ขยาย `POST /api/documents/{documentId}/versions`: รับ `fields` (map tag→text) → เทียบกับ `TemplateField` ของ template ที่ document ผูกอยู่ → update `DocumentFieldValue` (latest value) + เขียน `DocumentVersion.FieldsSnapshotJson` ของ version นั้น
|
||||
- [ ] field ที่ส่งมาขาด (ผู้ใช้ลบ content control) → เก็บค่าเดิม + mark missing ใน snapshot **ไม่ fail request**; field ที่ส่งมาเกินนิยาม → ignore
|
||||
|
||||
**DoD:** upload template จริง (เช่น สัญญาจ้างงานที่มี content controls) → detect fields ครบ → เรียก generate ผ่าน Swagger → ได้ docx ที่ (1) เปิดใน Word แล้วค่าถูกทุก field formatting ไม่พัง (2) Word บอก custom properties มี `SavedByAddIn`/`DocumentTrackingId` (ตรวจใน Advanced Properties หรือเปิดไฟล์ด้วย unzip) (3) DB มี Document + v1 + field values (AC-11, 12 ส่วน backend ผ่าน) และ (4) เรียก upload version ถัดไปพร้อม `fields` ที่เปลี่ยนค่า → latest values + snapshot อัปเดตถูก / ขาด field → ค่าเดิมคงอยู่ไม่ error (AC-13, 14 ส่วน backend ผ่าน)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Add-in services layer — FR-8 (ฝั่ง client), FR-11 (extraction), โครง FR-1/2
|
||||
|
||||
**เป้า:** logic ทั้งหมดที่ UI จะเรียกใช้ + unit test — ตรง spec ส่วน 11 (frontend ข้อ 2, 3, 5, 6)
|
||||
|
||||
### 4.1 Auth (`authService.ts`, `auth-callback.html`)
|
||||
- [ ] สร้าง `code_verifier` (random 43–128 chars) + `code_challenge` = base64url(SHA256(verifier))
|
||||
- [ ] สร้าง authorization URL: Keycloak endpoint + client_id + redirect_uri (`auth-callback.html`) + response_type=code + scope=openid + state + code_challenge (S256)
|
||||
- [ ] เปิด `Office.context.ui.displayDialogAsync(url, {height: 60, width: 30})` + `Dialog.addEventHandler(Office.EventType.DialogMessageReceived)` รับ message กลับ
|
||||
- [ ] `auth-callback.html` — parse `code`/`state` จาก URL, `Office.context.ui.messageParent(JSON.stringify({code, state}))` แล้ว `window.close()`
|
||||
- [ ] แลก token ที่ Keycloak token endpoint ด้วย verifier (public client ไม่พึ่ง backend)
|
||||
- [ ] เก็บ `access_token`/`refresh_token` **ใน JS variable ของ task pane session**; expose `isTokenValid()`, `getAccessToken()`
|
||||
- [ ] Silent refresh ก่อนหมดอายุ + ถ้า refresh token ตาย/ถูก revoke → คืนสถานะ "ต้อง login ใหม่" แบบ graceful (ไม่ error ค้าง)
|
||||
- [ ] ป้องกัน CSRF ด้วยการเทียบ `state`
|
||||
|
||||
### 4.2 Metadata (`metadataService.ts`)
|
||||
- [ ] อ่าน Custom Properties: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`
|
||||
- [ ] เขียน/stamp properties (ใช้ property `add()` ของใหม่ทับ — ไม่มีปัญหาแบบ XML part)
|
||||
- [ ] XML part ฝั่งอ่าน: หา part ที่ namespace `urn:myaddin:versioning` → parse เป็น list
|
||||
- [ ] XML part ฝั่งเขียน: **delete ของเดิมทั้งหมดก่อนค่อย `add()` ใหม่** + **catch error แบบไม่ throw** (known bug บน Mac — เขียนไม่ได้ก็ปล่อยผ่านเพราะ backend คือ source of truth)
|
||||
- [ ] Constants: `ADDIN_SIGNATURE_GUID`
|
||||
- [ ] **(FR-11) `readContentControlFields()`** — `Word.run` → `context.document.contentControls` → `load("tag, text")` → คืน `{ [tag]: text }`; เอกสารที่ไม่มี content controls → คืน `{}` ไม่ error
|
||||
|
||||
### 4.3 File + API (`fileService.ts`, `apiClient.ts`, `versioningService.ts`)
|
||||
- [ ] `fileService` — `getFileAsync(Office.FileType.Compressed)` → อ่าน slice ทีละชิ้น → ประกอบเป็น Blob → **เรียก `closeAsync` เสมอ** (จำกัดจำนวน file object ที่เปิดพร้อมกันได้)
|
||||
- [ ] `apiClient` — wrapper fetch ที่แนบ `Authorization: Bearer <token>` ทุก request; โดน 401 → สั่ง refresh token แล้ว retry ครั้งเดียว; ยัง 401 → ส่งสัญญาณให้ UI กลับไปหน้า sign-in
|
||||
- [ ] `versioningService` — `uploadVersion(documentId, blob, clientVersionId, fields)`, `getVersions(documentId)`, `getDownloadUrl(...)`/download — **fields = map จาก `readContentControlFields()`**
|
||||
- [ ] `models/versionRecord.ts` — type ตรงกับ response ของ backend (รวม snapshot fields ต่อ version)
|
||||
|
||||
### 4.4 Manifest
|
||||
- [ ] `manifest.xml` ประกาศ requirement sets ให้ครอบ `WordApi 1.5` (customProperties, customXmlParts, `document.save()`) + `DialogAPI`
|
||||
|
||||
**DoD:** `npm run build` ผ่าน + unit tests ผ่าน (Jest: mock `Word.run`, `Office.context.ui`, `fetch`, Keycloak token response) — ครอบ PKCE flow, delete-then-add XML part, apiClient retry-on-401, fileService ประกอบ slice, **readContentControlFields แปลง contentControls → map ถูกต้อง (รวมกรณี control ซ้ำ tag → เอาตัวแรก + tag ว่าง → ข้าม)**
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Task pane UI — FR-1, FR-2, FR-3, FR-4, FR-11 (ส่วนส่งค่า)
|
||||
|
||||
**เป้า:** UI ครบทุก flow — ตรง spec ส่วน 11 (frontend ข้อ 4)
|
||||
|
||||
- [ ] `taskpane.tsx` state machine หลัก: `checking → signed-out → untracked → tracking`
|
||||
- [ ] **Sign-in gate (FR-1):** `Office.onReady()` → เช็ค token ก่อน — ไม่มี/หมดอายุ → หน้า "Sign in" (ปุ่มเรียก authService); มี → เช็ค property `SavedByAddIn` เทียบ `ADDIN_SIGNATURE_GUID`
|
||||
- ตรง → อ่าน `DocumentTrackingId` → `getVersions()` → เข้าสถานะ `tracking` (**ก็คือ flow ของไฟล์ที่สร้างจากเว็บ — เปิดปุ๊บเห็น v1 ทันที ไม่ต้อง Start Versioning**)
|
||||
- ไม่ตรง → ปุ่ม **"Start Versioning This Document"**
|
||||
- [ ] **Start Versioning:** stamp metadata รอบแรก (`SavedByAddIn`, `DocumentTrackingId` ใหม่) → upload version แรก → สลับเป็น `tracking`
|
||||
- [ ] `ConfirmVersionDialog.tsx` — ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?"
|
||||
- [ ] **Save & Version flow (FR-2) ตามลำดับ spec แบบเคร่งครัด:**
|
||||
1. เปิด confirm dialog
|
||||
2. Stamp properties: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`
|
||||
3. อัปเดต XML part (delete-then-add, fail ได้แบบ silent)
|
||||
4. `context.document.save(Word.SaveBehavior.save)`
|
||||
5. `fileService` ดึงไฟล์ปัจจุบัน + **`readContentControlFields()` ดึงค่า fields ปัจจุบัน (FR-11)**
|
||||
6. `POST /api/documents/{documentId}/versions` — แนบ `fields` ด้วย
|
||||
7. แสดงผล + refresh history
|
||||
- ทุก step มี loading state + error message + **ห้ามปล่อย unhandled rejection**
|
||||
- [ ] `VersionHistoryPanel.tsx` (FR-4) — list version number/timestamp/ผู้สร้าง + ปุ่ม "Download this version" (`GET .../download`)
|
||||
- [ ] **Soft reminder (FR-3):** polling/debounce ทุก ~30 วินาทีเช็ค `context.document.saved` — `false` ต่อเนื่องเกิน threshold → banner "มีการแก้ไขที่ยังไม่ได้ version" (ไม่ block ใคร)
|
||||
- [ ] จัดการ token หมดอายุกลาง session: 401 → silent refresh → ยังไม่ได้ → กลับหน้า sign-in อย่างเนียน (AC-9)
|
||||
- [ ] Fluent UI components ให้หน้าตาเข้ากับ Office
|
||||
|
||||
**DoD:** sideload บน Word Desktop (Mac ที่มีอยู่ + Windows ถ้ามี) และ Word Online → ครบ flow: login ผ่าน dialog, start versioning, save & version, เห็น history, download ไฟล์ย้อนหลังเปิดได้ปกติ (AC-1, 2, 4, 5, 6, 7, 8 ผ่าน) + แก้ค่าใน content control แล้ว Save & Version → DB field values เปลี่ยนตาม (AC-13) + เปิดไฟล์ที่ generate จากเว็บ (Phase 6 เสร็จ) → เห็นเป็น tracked + v1 ทันที (AC-12)
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Web Portal — FR-9 (UI), FR-10 (UI) *(ใหม่)*
|
||||
|
||||
**เป้า:** เว็บ Nuxt + Tailwind สำหรับจัดการ template และสร้างเอกสาร ทุกหน้ารองรับ light/dark theme
|
||||
|
||||
### 6.1 พื้นฐาน
|
||||
- [ ] `useAuth.ts` — OIDC PKCE + **browser redirect ปกติ** (top-level page ใช้ได้เลย ไม่ต้องมี Dialog API), รับ code ที่ route callback, แลก token, silent refresh, token ใน memory
|
||||
- [ ] `useApi.ts` — fetch wrapper + Bearer + auto refresh ตอน 401
|
||||
- [ ] `useTheme.ts` — toggle light/dark (สลับ class บน `<html>`), persist ใน localStorage, default ตาม `prefers-color-scheme`
|
||||
- [ ] Layout หลัก: header + theme toggle + สถานะ login (ทุกหน้าใช้ร่วม)
|
||||
|
||||
### 6.2 จัดการ template (FR-9 UI)
|
||||
- [ ] `templates/index.vue` — ตารางรายการ (name, description, จำนวน fields, active) + ปุ่ม deactivate/activate
|
||||
- [ ] `templates/new.vue` + `templates/[id]/edit.vue` — upload/แทนที่ไฟล์ .docx → โชว์ **field list ที่ระบบ detect อัตโนมัติ** → ฟอร์มตั้ง label/type (text/number/date)/required/ลำดับ → บันทึก
|
||||
|
||||
### 6.3 สร้างเอกสาร (FR-10 UI)
|
||||
- [ ] `index.vue` — card รายการ template ที่ active → เลือกแล้วไปหน้าสร้าง
|
||||
- [ ] `documents/create/[templateId].vue` — **dynamic form สร้างจาก field defs** (input type ตาม field type: text/number/date picker), validate required/type ฝั่ง client + แสดง error จาก backend
|
||||
- [ ] กดสร้าง → แสดงผล: documentId, version 1 + **ปุ่มดาวน์โหลด .docx** + คำแนะนำสั้นๆ ("เปิดไฟล์นี้ใน Word แล้วใช้ add-in ต่อ")
|
||||
|
||||
**DoD:** login ผ่าน Keycloak บนเว็บได้ → สร้าง/แก้ template จากไฟล์จริง detect fields ถูก → กรอก form สร้างเอกสาร ได้ docx ครบค่า → เปิดใน Word แล้ว add-in รู้จักทันทีเห็น v1 (AC-11, 12 เต็มรูปแบบ) + theme toggle ทำงานทุกหน้าและจำค่าได้หลัง reload
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Tests
|
||||
|
||||
**เป้า:** ความมั่นใจก่อนเรียกว่าเสร็จ — ตรง spec ส่วน 11 (frontend ข้อ 7, backend ข้อ 7) + ครอบ features ใหม่
|
||||
|
||||
- [ ] **Frontend unit (Jest, ทำพร้อม Phase 4–5):**
|
||||
- authService: PKCE pair ถูก format, state validation, token exchange, refresh flow
|
||||
- metadataService: stamp ครบ 4 properties, XML delete-then-add, XML fail แล้วไม่ throw, **readContentControlFields แปลง map ถูก (tag ซ้ำ/ว่าง/ไม่มี control)**
|
||||
- apiClient: แนบ Bearer, retry หลัง refresh, ยอมแพ้หลัง retry ครั้งที่ 2
|
||||
- fileService: ประกอบ slice หลากขนาดเป็น Blob, เรียก closeAsync
|
||||
- [ ] **Backend integration (xUnit + Testcontainers):** รัน PostgreSQL + MinIO + Keycloak จริง
|
||||
- ขอ token ผ่าน Keycloak admin API สร้าง test user
|
||||
- upload → มี record + object; upload ซ้ำ document → versionNumber เพิ่ม; download → bytes ตรง; ไม่มี token → 401; ไฟล์ไม่ใช่ docx → 400; `CreatedByUserId` ตรงกับ `sub` ใน JWT
|
||||
- **Template:** upload docx มี SDT → detect tags ครบ; บันทึก field defs; deactivate แล้ว generate ไม่ได้แต่ document เก่ายังโหลดได้
|
||||
- **Generate:** แทนที่ค่าครบทุก field + เปิดผลลัพธ์ด้วย OpenXML ตรวจ SDT text ตรง + **custom properties มี `SavedByAddIn`/`DocumentTrackingId`** + ได้ v1 + field values ใน DB; required ขาด → 400; type ผิด → 400
|
||||
- **Field sync:** upload version พร้อม `fields` → latest values อัปเดต + snapshot เก็บต่อ version; **ส่ง map ขาด field → ค่าเดิมคงอยู่ + mark missing + request สำเร็จ (AC-14)**
|
||||
- [ ] **Web (Vitest):** validation ของ dynamic form (required/type) + useTheme logic
|
||||
|
||||
**DoD:** `npm test` (addin), `npm test` (web) และ `dotnet test` เขียวทั้งคู่
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: README + ตรวจ acceptance criteria
|
||||
|
||||
- [ ] `README.md`: วิธีรัน local dev (docker compose, dotnet run, npm start, **npm run dev ของ /web**), วิธี sideload add-in บน Desktop (Win/Mac) + Word Online, ค่า connection config, เข้า MinIO console, เข้า Keycloak admin console, สร้าง test user แรก, **วิธีสร้าง template แรก (ตัวอย่างไฟล์ docx ที่มี content controls) และสร้างเอกสารจากเว็บ** — ตรง spec ส่วน 11 (backend ข้อ 9)
|
||||
- [ ] ไล่ checklist acceptance criteria ทีละข้อ พร้อมจดผล/ปัญหาที่เจอ
|
||||
|
||||
---
|
||||
|
||||
## 4. ตาราง mapping: Phase ↔ FR ↔ Acceptance Criteria
|
||||
|
||||
| Phase | FR ที่ implement | Acceptance criteria ที่ตรวจได้ |
|
||||
|---|---|---|
|
||||
| 0 | — | — (โครง) |
|
||||
| 1 | FR-8 (Keycloak setup รวม web client) | — (infra) |
|
||||
| 2 | FR-5, FR-6, FR-7, FR-8 (backend) | AC-3 |
|
||||
| 3 | FR-9, FR-10 (backend) | AC-11, AC-12 (ส่วน API) |
|
||||
| 4 | FR-8 (client auth), FR-11 (extraction), โครง FR-1/2 | AC-2 (ส่วน dialog) |
|
||||
| 5 | FR-1, FR-2, FR-3, FR-4, FR-11 (ส่วนส่งค่า) | AC-1, 2, 4, 5, 6, 7, 8, 9, 10, 13 |
|
||||
| 6 | FR-9 (UI), FR-10 (UI) | AC-11, AC-12 (เต็มรูปแบบ) |
|
||||
| 7 | — | คุ้มกัน regression ทุก AC (รวม AC-13, 14) |
|
||||
| 8 | — | ทบทวน AC-1 ถึง AC-14 ทั้งหมด |
|
||||
|
||||
**AC เพิ่มเติม (นอก spec เดิม):**
|
||||
- **AC-11:** upload template .docx ที่มี content controls → ระบบ detect fields อัตโนมัติ + บันทึก field definitions ได้
|
||||
- **AC-12:** กรอก form บนเว็บ → ดาวน์โหลด .docx ที่ค่าครบถูกต้อง → เปิดใน Word แล้ว add-in รายงานเป็น tracked document เห็น version 1 ทันที **โดยไม่ต้องกด Start Versioning**
|
||||
- **AC-13:** แก้ค่าใน content control ที่ Word แล้วกด Save & Version → field value ใน DB เปลี่ยนตาม + snapshot ต่อ version ถูกเก็บ
|
||||
- **AC-14:** ลบ content control ออกจาก Word แล้วกด Save & Version → flow ยังสำเร็จ ค่าเดิมของ field นั้นคงอยู่ (mark missing) ไม่ error
|
||||
|
||||
---
|
||||
|
||||
## 5. ความเสี่ยงและ mitigation
|
||||
|
||||
| ความเสี่ยง | ผลกระทบ | Mitigation |
|
||||
|---|---|---|
|
||||
| Custom XML Part fail แบบ silent บน Word for Mac | history ในไฟล์หาย | ออกแบบไว้แล้ว: XML part เป็น secondary, backend คือ source of truth; metadataService catch ไม่ throw (AC-10) |
|
||||
| ผู้ใช้เผลอ save ด้วย Ctrl+S แทนปุ่ม | ไม่มี version ใหม่ | ห้ามดักไม่ได้ตามข้อจำกัดแพลตฟอร์ม → FR-3 banner เตือนเมื่อ `document.saved === false` นานเกิน threshold |
|
||||
| Access token หมดอายุกลางใช้งาน | request ล้ม 401 | apiClient/useApi auto-refresh + retry; refresh token ตาย → กลับหน้า sign-in เนียนๆ (AC-9) |
|
||||
| ไฟล์ .docx ขนาดใหญ่ | upload ช้า/ล้ม | limit 100MB, stream upload ลง MinIO, SHA256 คำนวณระหว่าง stream |
|
||||
| `getFileAsync` กิน memory/เปิดค้าง | resource leak ใน Word | เรียก `closeAsync` เสมอใน finally |
|
||||
| Keycloak login โดน iframe blocker | login ไม่ได้เลย | ใช้ Dialog API ตั้งแต่ดีไซน์ (FR-8); `/web` เป็น top-level ไม่กระทบ |
|
||||
| versionNumber race (สอง client upload พร้อมกัน) | version ซ้ำ → ผิด | unique index `(DocumentId, VersionNumber)` + retry ตอนชน constraint |
|
||||
| **OpenXML replace ทำ formatting พัง** | เอกสารที่ generate หน้าตาผิดจาก template | แก้ที่ **run level ใน SDT** (ไม่ทับ paragraph) + integration test ด้วย template จริงหลายรูปแบบ |
|
||||
| **ผู้ใช้ลบ/แก้ content control ใน Word** | field sync ไม่ครบ/ไม่ตรง | FR-11 policy: field หาย → เก็บค่าเดิม + mark missing, ไม่ fail (AC-14); tag ซ้ำ → เอาตัวแรก |
|
||||
| **Template ถูกใช้กับ document เก่าจำนวนมาก** | ลบ template แล้วอ้างอิงพัง | soft-delete (IsActive) เท่านั้น; Document เก่ายังถือ TemplateId เดิมได้ |
|
||||
| **Keycloak 3 clients + CORS 2 origins** | auth พังเฉพาะบาง client | realm-export ครบทุก client ตั้งแต่ Phase 1; backend CORS ใส่ทั้ง add-in และ web |
|
||||
| **Port ชนกัน (Nuxt default 3000 = add-in)** | dev รันชนกันสับสน | บังคับ Nuxt ที่ **5173** ใน `nuxt.config.ts` ตั้งแต่ Phase 0 |
|
||||
|
||||
---
|
||||
|
||||
## 6. คำสั่งที่ใช้ประจำ (เมื่อ implement จริง)
|
||||
|
||||
```bash
|
||||
# infra
|
||||
docker compose up -d
|
||||
|
||||
# backend
|
||||
cd backend/WordVersioning.Api
|
||||
dotnet run # รัน + swagger ที่ http://localhost:5xxx
|
||||
dotnet ef migrations add <Name>
|
||||
dotnet test
|
||||
|
||||
# add-in (https://localhost:3000)
|
||||
cd addin
|
||||
npm install
|
||||
npm run build # webpack build
|
||||
npm start # dev server — sideload ผ่าน manifest นี้
|
||||
npm test # jest
|
||||
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว เพื่อให้ https localhost ใช้ได้
|
||||
|
||||
# web portal (http://localhost:5173 — ห้ามใช้ 3000 เพราะชน add-in)
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
npm test # vitest
|
||||
```
|
||||
390
specs/spec-officejs.md
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
# Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend)
|
||||
|
||||
> ฉบับแก้ไข v2 (2026-08-18): เพิ่ม Web Portal (Nuxt.js) + Template System (FR-9 ถึง FR-11) + field sync — implementation plan อยู่ที่ `docs/plans/officejs-implementation-plan.md`
|
||||
|
||||
## 1. ภาพรวม
|
||||
|
||||
สร้างระบบ 3 ส่วนที่ทำงานร่วมกัน:
|
||||
|
||||
1. **Office Add-in (Office.js, TypeScript)** — รันได้บน Word Desktop (Windows/Mac) และ Word Online โดยไม่ต้อง build แยก
|
||||
2. **.NET 8 Web API Backend** — เก็บ version metadata ใน DB และไฟล์ .docx จริงใน **MinIO (S3-compatible object storage)** + ระบบ template และ generate เอกสาร
|
||||
3. **Web Portal (Nuxt.js + TailwindCSS)** — เว็บแยกสำหรับจัดการ template และกรอก form สร้างเอกสารครั้งแรกจาก template โดยเป็น **version 1 อัตโนมัติ**
|
||||
|
||||
ความสามารถหลัก (เทียบเท่า VSTO แต่ปรับ flow ให้เข้ากับข้อจำกัดของแพลตฟอร์ม):
|
||||
|
||||
1. ตรวจจับได้ว่าไฟล์ .docx ที่เปิดอยู่เคยถูกบันทึกโดย add-in นี้หรือไม่
|
||||
2. มีปุ่ม **"Save & Version"** ใน Task Pane ให้ผู้ใช้กดเพื่อยืนยันสร้าง version (แทนการดัก native save เพราะ Office.js ไม่มี before-save event)
|
||||
3. เมื่อกด Save & Version จะ stamp tag ลง metadata ของไฟล์ + อัปโหลด snapshot ไป backend **+ extract ค่าจาก content controls ไป update fields ใน DB ให้ sync (FR-11)**
|
||||
4. Backend เก็บ version history แบบ query ได้, ดาวน์โหลด version ย้อนหลังได้
|
||||
5. จัดการ template เอกสาร (.docx ที่มี content controls) ผ่านหน้าเว็บ — upload แล้วระบบ **detect fields อัตโนมัติ** (FR-9)
|
||||
6. สร้างเอกสารครั้งแรกจาก template: กรอก form บนเว็บ → backend generate .docx + stamp metadata + สร้าง **version 1 อัตโนมัติ** → เปิดใน Word แล้ว add-in รู้จักทันที (FR-10)
|
||||
|
||||
**ขอบเขต**: รองรับ Word Desktop (Win/Mac) + Word on the Web
|
||||
|
||||
---
|
||||
|
||||
## 2. ⚠️ ข้อจำกัดของแพลตฟอร์มที่ต้องออกแบบรองรับ
|
||||
|
||||
- **ไม่มี native "before save" / "before close" event ที่ cancel ได้** ใน Word JS API (ต่างจาก VSTO) → ห้ามออกแบบ flow ที่พึ่งพาการดัก Ctrl+S หรือ File>Save ของผู้ใช้ ต้องใช้ explicit action (ปุ่มใน task pane / ribbon command) แทน
|
||||
- **Custom XML Parts แก้ไขตรงๆ ไม่ได้** — ต้อง `delete()` ของเดิมแล้ว `add()` ใหม่ทุกครั้งที่จะอัปเดต
|
||||
- **Custom XML Parts มี known bug บางแพลตฟอร์ม** (เช่น ไม่ persist หลัง save/reopen บน Excel/Word for Mac บาง build) → ใช้เป็น secondary storage เท่านั้น อย่าพึ่งเป็นแหล่งข้อมูลเดียว
|
||||
- **Custom Document Properties เสถียรกว่าและ cross-platform ดีกว่า** → ใช้เป็น primary flag สำหรับตรวจจับว่าไฟล์ผ่าน add-in นี้หรือไม่
|
||||
- Manifest ต้อง support ทั้ง requirement set ที่ `document.customXmlParts`, `document.properties.customProperties`, และ `document.save()` (WordApi 1.5+) ใช้งานได้
|
||||
|
||||
---
|
||||
|
||||
## 3. เทคโนโลยี
|
||||
|
||||
### Add-in (frontend)
|
||||
- Office Add-in project scaffold จาก `yo office` (Yeoman generator)
|
||||
- TypeScript + React (task pane UI)
|
||||
- Office.js / Word JavaScript API (`office-js`, ใช้ `@types/office-js`)
|
||||
- Fluent UI React components (ให้เข้ากับ Office look & feel)
|
||||
|
||||
### Web Portal (เว็บแยก) — เพิ่มเติม v2
|
||||
- Nuxt.js (Vue 3) + TailwindCSS — ทุกหน้ารองรับ **light/dark theme** (toggle + persist + default ตาม `prefers-color-scheme`, Tailwind `darkMode: 'class'`)
|
||||
- Auth ผ่าน Keycloak client `word-versioning-web` (public + PKCE) ด้วย OIDC browser redirect ปกติ — เป็น top-level page จึง**ไม่ต้องใช้ Office Dialog API**
|
||||
- Dev server ที่ **port 5173** (ห้ามใช้ 3000 เพราะชนกับ add-in dev server)
|
||||
|
||||
### Backend
|
||||
- ASP.NET Core 8 Web API
|
||||
- Entity Framework Core + **PostgreSQL** (provider `Npgsql.EntityFrameworkCore.PostgreSQL`)
|
||||
- **DocumentFormat.OpenXml (OpenXML SDK)** — parse content controls จาก template, แทนที่ค่าตอน generate, stamp Custom Properties ลงไฟล์
|
||||
- **MinIO** สำหรับเก็บไฟล์ .docx จริง — ใช้ AWS SDK for .NET (`AWSSDK.S3`) เชื่อมต่อผ่าน S3-compatible API (MinIO รองรับ S3 API เต็มรูปแบบ) โดย config endpoint ชี้ไปที่ MinIO instance แทน AWS จริง
|
||||
- ออกแบบผ่าน interface (`IObjectStorageService`) เพื่อ swap ไป provider อื่นที่รองรับ S3 API ได้ในอนาคต (AWS S3, Azure Blob ผ่าน S3-compat gateway, ฯลฯ) โดยไม่กระทบ business logic
|
||||
- Auth: **Keycloak** (OpenID Connect) — Add-in ทำ OIDC login flow ผ่าน Keycloak แล้วได้ access token (JWT) มาแนบกับทุก request ไป backend, backend validate token ผ่าน Keycloak's public key (JWKS endpoint)
|
||||
|
||||
---
|
||||
|
||||
## 4. โครงสร้างโปรเจกต์ที่ต้องการ
|
||||
|
||||
```
|
||||
/word-versioning-addin
|
||||
/addin # Office.js task pane project (yo office scaffold)
|
||||
manifest.xml
|
||||
/src
|
||||
/taskpane
|
||||
taskpane.html
|
||||
taskpane.ts
|
||||
auth-callback.html # หน้ารับ redirect จาก Keycloak ภายใน Dialog API
|
||||
/components
|
||||
VersionHistoryPanel.tsx
|
||||
ConfirmVersionDialog.tsx
|
||||
/services
|
||||
metadataService.ts # custom properties + custom XML part logic
|
||||
versioningService.ts # เรียก backend API
|
||||
fileService.ts # getFileAsync/slice → Blob
|
||||
authService.ts # Keycloak OIDC + PKCE login flow, token refresh
|
||||
apiClient.ts # แนบ Authorization: Bearer <token> ทุก request
|
||||
/models
|
||||
versionRecord.ts
|
||||
package.json
|
||||
webpack.config.js
|
||||
|
||||
/backend
|
||||
/WordVersioning.Api
|
||||
Program.cs
|
||||
/Controllers
|
||||
DocumentsController.cs # FR-5 ถึง FR-7 + generate (FR-10) + field sync (FR-11)
|
||||
TemplatesController.cs # FR-9 template CRUD + auto-detect fields
|
||||
/Models
|
||||
Document.cs # + TemplateId
|
||||
DocumentVersion.cs # + FieldsSnapshotJson
|
||||
Template.cs
|
||||
TemplateField.cs
|
||||
DocumentFieldValue.cs
|
||||
/Data
|
||||
AppDbContext.cs
|
||||
/Services
|
||||
ObjectStorageService.cs # wrapper เรียก MinIO ผ่าน AWSSDK.S3 (bucket docx-versions + docx-templates)
|
||||
HashService.cs
|
||||
DocxGenerationService.cs # FR-10: แทนที่ค่าใน SDT + stamp custom properties
|
||||
appsettings.json # เก็บ MinIO endpoint/keys, Keycloak Authority/Audience
|
||||
WordVersioning.Api.csproj
|
||||
|
||||
/web # Web Portal — เพิ่มเติม v2 (Nuxt.js + TailwindCSS)
|
||||
nuxt.config.ts # devServer port 5173
|
||||
tailwind.config.ts # darkMode: 'class'
|
||||
/pages # เลือก template สร้างเอกสาร / จัดการ template / dynamic form
|
||||
/composables # useAuth (PKCE redirect), useApi, useTheme (light/dark)
|
||||
package.json
|
||||
|
||||
/keycloak
|
||||
realm-export.json # pre-configured realm/client สำหรับ local dev import อัตโนมัติ
|
||||
|
||||
docker-compose.yml # รัน MinIO + Keycloak + backend + DB สำหรับ local dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Functional Requirements — Add-in (Frontend)
|
||||
|
||||
### FR-1: ตรวจจับไฟล์ที่เคยบันทึกจาก add-in นี้
|
||||
- เมื่อ `Office.onReady()` ให้เช็คสถานะ login ก่อน (มี valid access token ใน session หรือไม่) — ถ้ายังไม่ login ให้แสดงปุ่ม "Sign in" (trigger login flow ตาม FR-8) ก่อนเข้าถึงฟีเจอร์อื่น
|
||||
- ตรวจ Custom Document Property `SavedByAddIn` เทียบกับค่าคงที่ `ADDIN_SIGNATURE_GUID`
|
||||
- ถ้าตรง → โหลด `DocumentTrackingId` จาก properties แล้วเรียก backend (แนบ Bearer token) เพื่อดึง version history มาแสดงใน task pane
|
||||
- ถ้าไม่ตรง/ไม่มี → แสดง UI "เริ่มต้น track เอกสารนี้" (ปุ่ม "Start Versioning This Document")
|
||||
|
||||
### FR-2: ปุ่ม "Save & Version" (แทนการดัก native save)
|
||||
Flow เมื่อกด:
|
||||
1. แสดง dialog ยืนยัน ("สร้าง version ใหม่ของเอกสารนี้หรือไม่?")
|
||||
2. Stamp Custom Document Properties: `SavedByAddIn`, `DocumentTrackingId` (สร้างใหม่ถ้ายังไม่มี), `LastVersionId`, `LastVersionTimestamp`
|
||||
3. อัปเดต Custom XML Part สำหรับ version history (delete-then-add pattern)
|
||||
4. เรียก `context.document.save(Word.SaveBehavior.save)` เพื่อ trigger native save
|
||||
5. ดึงไฟล์ปัจจุบันด้วย File/Slice API (`Office.context.document.getFileAsync`)
|
||||
6. อัปโหลดไปยัง backend endpoint `POST /api/documents/{documentId}/versions` พร้อมแนบค่า fields ที่ extract จาก content controls ของเอกสาร (FR-11)
|
||||
7. แสดงผลลัพธ์ (success/error) ใน task pane, refresh version history list
|
||||
|
||||
### FR-3: Soft reminder เมื่อยังไม่ได้ version
|
||||
- เนื่องจากดัก native save ไม่ได้ ให้ใช้ `Document.addHandlerAsync(Office.EventType.DocumentSelectionChanged)` หรือ polling เบาๆ (เช่น debounce ทุก 30 วินาที) ตรวจ `context.document.saved` — ถ้า `false` ต่อเนื่องนานเกิน threshold ให้แสดง banner ใน task pane เตือนว่า "มีการแก้ไขที่ยังไม่ได้ version" (ไม่ใช่ hard block)
|
||||
|
||||
### FR-4: Version History Panel
|
||||
- แสดงรายการ version จาก backend: version number, timestamp, ผู้สร้าง
|
||||
- ปุ่ม "Download this version" → เรียก `GET /api/documents/{documentId}/versions/{versionId}/download`
|
||||
|
||||
---
|
||||
|
||||
## 6. Functional Requirements — Backend API, Template System & Web Portal
|
||||
|
||||
### FR-5: Upload version
|
||||
```
|
||||
POST /api/documents/{documentId}/versions
|
||||
Content-Type: multipart/form-data
|
||||
Body: file (docx binary), clientVersionId (guid)
|
||||
```
|
||||
- ถ้า `documentId` ยังไม่มีใน DB ให้สร้าง `Document` record ใหม่
|
||||
- อัปโหลดไฟล์ไป MinIO bucket (เช่น `docx-versions`) ที่ object key: `{documentId}/v{versionNumber:D4}_{clientVersionId}.docx`
|
||||
- คำนวณ SHA256 hash ของไฟล์ เก็บลง DB (ใช้ตรวจสอบไฟล์ซ้ำ/integrity ภายหลัง)
|
||||
- บันทึก `DocumentVersion` record (version number = auto increment ต่อ document)
|
||||
- Response: `{ versionId, versionNumber }`
|
||||
|
||||
### FR-6: ดึง version history
|
||||
```
|
||||
GET /api/documents/{documentId}/versions
|
||||
```
|
||||
- คืน list ของ `DocumentVersion` เรียงจากใหม่ไปเก่า
|
||||
|
||||
### FR-7: ดาวน์โหลด version ที่ระบุ
|
||||
```
|
||||
GET /api/documents/{documentId}/versions/{versionId}/download
|
||||
```
|
||||
- Stream ไฟล์จาก MinIO กลับไป พร้อม content-type ที่ถูกต้อง (ใช้ presigned URL หรือ proxy stream ผ่าน backend ก็ได้ — แนะนำ proxy stream เพื่อคุม auth ที่ endpoint เดียว ไม่ต้อง expose MinIO ตรงๆ ให้ client)
|
||||
|
||||
### FR-8: Auth (Keycloak / OpenID Connect)
|
||||
|
||||
**Keycloak setup**
|
||||
- สร้าง Realm แยกสำหรับระบบนี้ (เช่น `word-versioning`)
|
||||
- สร้าง Client 3 ตัว:
|
||||
- `word-addin-officejs` — **public client**, `Standard Flow` (Authorization Code) เปิด, บังคับ **PKCE** (`S256`), ปิด client secret (เพราะรันบน browser/task pane เข้าถึงได้)
|
||||
- `word-addin-vsto` — **public client** เช่นกัน สำหรับ desktop app (ดูรายละเอียด flow ใน `spec-vsto.md`)
|
||||
- `word-versioning-web` — **public client** สำหรับ Web Portal, บังคับ PKCE (`S256`), redirect URI `http://localhost:5173/*` — ใช้ OIDC browser redirect ปกติ (top-level page)
|
||||
- Redirect URI ของ `word-addin-officejs` ต้องชี้ไปหน้า redirect ภายใน add-in เอง (เช่น `https://localhost:3000/auth-callback.html` ตอน dev)
|
||||
|
||||
**Login flow ฝั่ง Office Add-in (Office.js)**
|
||||
> Task pane รันอยู่ใน iframe — Keycloak login page ปกติส่ง header `X-Frame-Options`/CSP กัน embed ใน iframe อยู่แล้ว จึง **ห้าม redirect iframe ตรงๆ ไป Keycloak** ต้องใช้ Office Dialog API แทน:
|
||||
|
||||
1. Task pane เรียก `Office.context.ui.displayDialogAsync(keycloakAuthUrl, { height: 60, width: 30 })` เปิด **dialog window แยก** (ไม่ใช่ iframe) ไปหน้า Keycloak login (Authorization Code + PKCE)
|
||||
2. ผู้ใช้ login ใน dialog นั้น → Keycloak redirect กลับมาที่ `auth-callback.html` (หน้าเล็กๆ ที่รันอยู่ใน dialog)
|
||||
3. `auth-callback.html` เรียก `Office.context.ui.messageParent(JSON.stringify({ code, state }))` ส่ง authorization code กลับไปที่ task pane แล้วปิด dialog เอง (`Office.context.ui.messageParent` + `window.close()`)
|
||||
4. Task pane รับ code → แลก token ที่ Keycloak token endpoint ด้วย PKCE verifier (ไม่ต้องพึ่ง backend ช่วยแลก เพราะเป็น public client)
|
||||
5. เก็บ `access_token`/`refresh_token` ไว้ใน memory (JS variable) ของ task pane session **ห้ามเก็บใน localStorage** (เสี่ยง XSS/ไม่หายเมื่อปิด task pane) — ถ้าต้อง persist ข้าม session ให้ใช้ `sessionStorage` เท่านั้นและ set อายุสั้น
|
||||
6. ก่อนหมดอายุ access token ให้ silent-refresh ด้วย `refresh_token` ผ่าน token endpoint
|
||||
|
||||
**Backend (ASP.NET Core)**
|
||||
- ใช้ `Microsoft.AspNetCore.Authentication.JwtBearer` config:
|
||||
```csharp
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.Authority = "http://keycloak:8080/realms/word-versioning";
|
||||
options.Audience = "word-addin-officejs"; // หรือ resource server audience ที่ตั้งไว้
|
||||
options.RequireHttpsMetadata = false; // dev เท่านั้น, prod ต้อง true
|
||||
});
|
||||
```
|
||||
- Backend validate token ผ่าน Keycloak's JWKS endpoint อัตโนมัติ (ไม่ต้อง hardcode public key)
|
||||
- ทุก endpoint ใน `DocumentsController` ต้องมี `[Authorize]`
|
||||
- ดึง `userId`/`username` จาก JWT claims (`sub`, `preferred_username`) มาใช้แทนการรับจาก client ตรงๆ (กัน client ปลอมค่า `CreatedByUserId`)
|
||||
|
||||
### FR-9: Template management (เพิ่มเติม v2)
|
||||
- CRUD template: upload ไฟล์ .docx template (ที่มี content controls), ตั้ง name/description/active flag
|
||||
- ตอน upload/replaces ไฟล์ ให้ backend **parse content controls (SDT tags) ออกมาเป็น field list อัตโนมัติ** ด้วย OpenXML
|
||||
- Admin ตั้งค่าต่อ field ผ่าน Web Portal: label, type (`text`/`number`/`date`), required, ลำดับ
|
||||
- ลบ template = **soft-delete (deactivate) เท่านั้น** เพราะ document เก่ายังอ้างอยู่ — เอกสารที่สร้างแล้วใช้ template ที่ deactivate ต่อได้
|
||||
- ไฟล์ template เก็บใน MinIO bucket `docx-templates` (private)
|
||||
|
||||
```
|
||||
POST /api/templates # upload + auto-detect fields (multipart: file + name + description)
|
||||
GET /api/templates # รายการ (filter active/all)
|
||||
PUT /api/templates/{id} # แก้ metadata + บันทึก field definitions
|
||||
PUT /api/templates/{id}/file # แทนที่ไฟล์ (re-detect fields, เตือนถ้า tag หายไป)
|
||||
DELETE /api/templates/{id} # deactivate (soft-delete)
|
||||
```
|
||||
|
||||
### FR-10: สร้างเอกสารจาก template ผ่าน Web Portal (เพิ่มเติม v2)
|
||||
```
|
||||
POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
|
||||
```
|
||||
- Validate ก่อน: required fields ครบ, type ถูก (number/date parse ได้)
|
||||
- `DocxGenerationService` (OpenXML): โหลด template จาก MinIO → **แทนที่ข้อความใน SDT แต่ละ field ที่ run level (รักษา formatting ของ template)** → **stamp Custom Properties** (`SavedByAddIn` = GUID ของ add-in, `DocumentTrackingId` = GUID ใหม่) ลงไฟล์ — ทำฝั่ง backend เท่านั้น เพื่อให้ v1 + metadata เกิดพร้อมกันแบบ atomic ไฟล์ที่ดาวน์โหลดถูกต้องสมบูรณ์ตั้งแต่ครั้งแรก
|
||||
- สร้าง `Document` record (ผูก `TemplateId`) + `DocumentFieldValue` ทุก field + **อัปโหลดเป็น version 1** + snapshot fields ของ v1
|
||||
- Response `{ documentId, versionId, versionNumber, downloadUrl }` → ผู้ใช้ดาวน์โหลด .docx ไปเปิดใน Word → **FR-1 ทำงานทันที** (เป็น tracked document เห็น v1 โดยไม่ต้องกด Start Versioning)
|
||||
|
||||
### FR-11: Field sync — update fields ใน DB ตอนสร้าง version (เพิ่มเติม v2)
|
||||
- ขยาย `POST /api/documents/{documentId}/versions` ให้รับ `fields` (map tag→text ที่ add-in extract มาตาม FR-2 ขั้นตอนที่ 6)
|
||||
- เทียบกับ `TemplateField` ของ template ที่ document ผูกอยู่ → update **latest value ต่อ field** (`DocumentFieldValue`) + เก็บ **snapshot ต่อ version** (`DocumentVersion.FieldsSnapshotJson` — ใช้ diff ใน history ได้)
|
||||
- field ที่ส่งมาขาด (ผู้ใช้ลบ content control ใน Word) → **เก็บค่าเดิม + mark missing ใน snapshot แต่ไม่ fail request**; ส่งมาเกินนิยาม → ignore
|
||||
- `UpdatedByUserId` มาจาก JWT claims ของผู้กด Save & Version เสมอ
|
||||
|
||||
---
|
||||
|
||||
## 7. Data Model (Backend)
|
||||
|
||||
```csharp
|
||||
public class Document
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string FileName { get; set; }
|
||||
public string AddInSignature { get; set; }
|
||||
public Guid? TemplateId { get; set; } // เอกสารที่สร้างจาก template (FR-10) — null ถ้า track เอกสารเปล่า
|
||||
public List<DocumentVersion> Versions { get; set; } = new();
|
||||
}
|
||||
|
||||
public class DocumentVersion
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid DocumentId { get; set; }
|
||||
public int VersionNumber { get; set; }
|
||||
public string ObjectKey { get; set; } // MinIO object key (bucket/path)
|
||||
public string ContentHash { get; set; }
|
||||
public string CreatedByUserId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? FieldsSnapshotJson { get; set; } // snapshot ค่า fields ของ version นี้ (FR-11)
|
||||
}
|
||||
|
||||
public class Template // เพิ่มเติม v2 (FR-9)
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string ObjectKey { get; set; } // ไฟล์ .docx ใน bucket docx-templates
|
||||
public bool IsActive { get; set; } = true; // soft-delete ผ่าน flag นี้
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public List<TemplateField> Fields { get; set; } = new();
|
||||
}
|
||||
|
||||
public class TemplateField
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid TemplateId { get; set; }
|
||||
public string Key { get; set; } // = content control (SDT) tag — unique ต่อ template
|
||||
public string Label { get; set; } // ชื่อที่แสดงใน form
|
||||
public string Type { get; set; } // text | number | date
|
||||
public bool IsRequired { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
public class DocumentFieldValue // latest value ต่อ field ของ document (FR-11)
|
||||
{
|
||||
public Guid DocumentId { get; set; }
|
||||
public string FieldKey { get; set; } // = TemplateField.Key
|
||||
public string Value { get; set; }
|
||||
public Guid SourceVersionId { get; set; } // version ล่าสุดที่อัปเดตค่านี้
|
||||
public string UpdatedByUserId { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Data Model (Frontend / metadata ในไฟล์ docx)
|
||||
|
||||
Custom Document Properties:
|
||||
| Key | Value |
|
||||
|---|---|
|
||||
| `SavedByAddIn` | GUID คงที่ของ add-in |
|
||||
| `DocumentTrackingId` | GUID เฉพาะของเอกสาร (ผูกกับ `documentId` ฝั่ง backend) |
|
||||
| `LastVersionId` | GUID ของ version ล่าสุด |
|
||||
| `LastVersionTimestamp` | ISO-8601 UTC timestamp |
|
||||
|
||||
Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact history เสริม (ไม่ใช่ source of truth, backend คือ source of truth):
|
||||
```xml
|
||||
<VersionHistory xmlns="urn:myaddin:versioning">
|
||||
<Version id="..." number="3" timestamp="..." user="..."/>
|
||||
<Version id="..." number="2" timestamp="..." user="..."/>
|
||||
</VersionHistory>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Non-Functional Requirements
|
||||
|
||||
- Manifest ต้อง sideload ได้บน Word Desktop (Win/Mac) และ Word Online เพื่อ dev/test ได้ทั้งสามแพลตฟอร์ม
|
||||
- Backend endpoint upload ต้อง handle ไฟล์ขนาดใหญ่ (จำกัด request size ให้เหมาะสม เช่น 100MB) และ reject content-type ที่ไม่ใช่ docx
|
||||
- ทุก error จาก Office.js call ต้อง catch และแสดงข้อความที่เข้าใจง่ายใน task pane (อย่าปล่อย unhandled promise rejection)
|
||||
- Backend ต้องมี CORS config อนุญาต origin ของ add-in (`https://localhost:3000` สำหรับ dev)
|
||||
- Logging ฝั่ง backend: log ทุก upload/download พร้อม documentId, versionId, userId
|
||||
- MinIO bucket ต้องสร้างแบบ **private** (ไม่ public read) — เข้าถึงไฟล์ได้ผ่าน backend API เท่านั้น (proxy stream หรือ presigned URL ที่หมดอายุสั้นๆ)
|
||||
- Config การเชื่อมต่อ MinIO (`Endpoint`, `AccessKey`, `SecretKey`, `BucketName`, `UseSSL`) ต้องแยกเป็น environment variable/`appsettings.json` ไม่ hardcode ในโค้ด เพื่อ deploy ไป production MinIO cluster ได้โดยไม่แก้โค้ด
|
||||
- Config Keycloak (`Authority`, `Audience`, `ClientId` ของแต่ละ client) ต้องแยกเป็น environment variable/`appsettings.json` เช่นกัน ไม่ hardcode
|
||||
- ถ้า access token หมดอายุระหว่างใช้งาน task pane ต้อง silent-refresh อัตโนมัติ ถ้า refresh token หมดอายุ/ถูก revoke ให้ redirect กลับไป login flow ใหม่แบบ graceful (ไม่ใช่ error ค้าง)
|
||||
|
||||
### เพิ่มเติม v2 (Template System & Web Portal)
|
||||
- **Field key = content control (SDT) tag** ต้อง unique ต่อ template — ใช้ tag เดียวกันทั้งตอน generate (backend), ตอน extract (add-in) และตอน sync (DB)
|
||||
- การ generate + stamp metadata ต้องทำฝั่ง backend เท่านั้น (OpenXML) — ไฟล์ที่ดาวน์โหลดจากเว็บต้องถูกต้องสมบูรณ์ตั้งแต่ครั้งแรก ไม่พึ่ง client ตั้งค่าใดๆ
|
||||
- OpenXML replace ต้องแก้ที่ **run level ใน SDT** เพื่อรักษา formatting ของ template — ห้ามทับทั้ง paragraph
|
||||
- Web Portal ทุกหน้ารองรับ **light/dark theme** (toggle + persist + default ตาม `prefers-color-scheme`)
|
||||
- **Dev server ports ห้ามชนกัน:** add-in = `https://localhost:3000`, Web Portal = `http://localhost:5173` (ตั้งใน `nuxt.config.ts`)
|
||||
- Backend CORS ต้องอนุญาตทั้ง origin ของ add-in (`https://localhost:3000`) และ Web Portal (`http://localhost:5173`)
|
||||
|
||||
---
|
||||
|
||||
## 10. Acceptance Criteria
|
||||
|
||||
1. Sideload add-in บน Word Desktop และ Word Online ได้ทั้งคู่ ไม่มี error ตอนโหลด task pane
|
||||
2. เปิด task pane ครั้งแรกโดยยังไม่ login → เห็นปุ่ม "Sign in", กดแล้วเปิด dialog ไป Keycloak login page ได้ถูกต้อง, login สำเร็จแล้ว dialog ปิดเองและ task pane ได้ token มาใช้งาน
|
||||
3. เรียก backend endpoint โดยไม่แนบ token → ได้ 401 Unauthorized
|
||||
4. เปิดไฟล์ .docx ใหม่ที่ไม่เคย track (หลัง login แล้ว) → task pane แสดงปุ่ม "Start Versioning This Document"
|
||||
5. กด "Start Versioning" → stamp metadata ครั้งแรก, สร้าง `Document` record ใน backend, อัปโหลด version 1 สำเร็จ, `CreatedByUserId` ใน DB ตรงกับ `sub`/`preferred_username` จาก JWT ของผู้ login
|
||||
6. ปิดแล้วเปิดไฟล์เดิมใหม่ (ทั้งบน Windows และ Mac) → task pane ตรวจพบว่าเป็น tracked document และโหลด version history จาก backend ได้ถูกต้อง
|
||||
7. แก้ไขเอกสารแล้วกด "Save & Version" → มี version ใหม่เพิ่มใน backend, `LastVersionId`/`LastVersionTimestamp` ใน properties อัปเดตถูกต้อง
|
||||
8. ดาวน์โหลด version เก่าจาก panel ได้ไฟล์ที่เปิดด้วย Word ได้ปกติ และเนื้อหาตรงกับตอน upload
|
||||
9. ปล่อย session ทิ้งไว้จนกว่า access token หมดอายุ แล้วลองใช้งานต่อ → ต้อง silent-refresh สำเร็จโดยผู้ใช้ไม่รู้สึกสะดุด (ไม่ต้อง login ใหม่)
|
||||
10. ทดสอบบน Word for Mac: Custom XML Part อาจ fail แบบ silent ตามที่ระบุใน spec — ต้องมี fallback ที่ระบบยังทำงานถูกต้องแม้ XML part เขียนไม่สำเร็จ (เพราะ backend คือ source of truth)
|
||||
11. (v2) upload template .docx ที่มี content controls ผ่านหน้าเว็บ → ระบบ detect fields อัตโนมัติ + บันทึก field definitions ได้
|
||||
12. (v2) กรอก form บนเว็บสร้างเอกสาร → ดาวน์โหลด .docx ที่ค่าครบถูกต้อง formatting ไม่พัง → เปิดใน Word แล้ว add-in รายงานเป็น tracked document เห็น version 1 ทันที โดยไม่ต้องกด Start Versioning
|
||||
13. (v2) แก้ค่าใน content control ที่ Word แล้วกด Save & Version → field value ใน DB เปลี่ยนตาม + snapshot ต่อ version ถูกเก็บ
|
||||
14. (v2) ลบ content control ออกจาก Word แล้วกด Save & Version → flow ยังสำเร็จ ค่าเดิมของ field นั้นคงอยู่ (mark missing) ไม่ error
|
||||
|
||||
---
|
||||
|
||||
## 11. งานที่ Claude Code ควรทำ
|
||||
|
||||
### Frontend
|
||||
1. Scaffold Office Add-in project ด้วย `yo office` (task pane, TypeScript, React)
|
||||
2. Implement `metadataService.ts`, `fileService.ts`, `versioningService.ts`, `authService.ts`, `apiClient.ts` — `metadataService` รวมการอ่าน content controls เป็น map `{tag: text}` สำหรับ field sync (FR-11)
|
||||
3. `authService.ts` ต้อง implement: build Keycloak authorization URL + PKCE code_verifier/code_challenge, เปิด `Office.context.ui.displayDialogAsync`, รับ code กลับผ่าน `messageParent`, แลก token, เก็บ token in-memory, silent refresh
|
||||
4. Implement `VersionHistoryPanel.tsx`, `ConfirmVersionDialog.tsx`, และหน้า sign-in state
|
||||
5. เขียน `auth-callback.html` (หน้าเล็กๆ รับ redirect จาก Keycloak แล้ว `messageParent` กลับ)
|
||||
6. เขียน manifest.xml ให้ครบ requirement sets ที่ใช้
|
||||
7. เขียน unit test สำหรับ service layer (mock `Word.run`, `fetch`, และ mock Keycloak token response)
|
||||
|
||||
### Backend
|
||||
1. Scaffold ASP.NET Core 8 Web API project
|
||||
2. Implement `AppDbContext`, migrations
|
||||
3. Config JWT Bearer authentication ให้ validate ผ่าน Keycloak Authority/JWKS ตาม FR-8
|
||||
4. Implement `DocumentsController` ตาม FR-5 ถึง FR-7 พร้อม `[Authorize]` ทุก endpoint, ดึง `userId` จาก JWT claims
|
||||
5. Implement `ObjectStorageService` (interface `IObjectStorageService`) เชื่อมต่อ MinIO ผ่าน `AWSSDK.S3` — ตั้ง `ServiceURL` ชี้ไป MinIO endpoint, `ForcePathStyle = true` (จำเป็นสำหรับ MinIO)
|
||||
6. เขียน `realm-export.json` สำหรับ Keycloak local dev — pre-config realm `word-versioning`, client `word-addin-officejs` (public, PKCE) และ `word-addin-vsto` (public, PKCE)
|
||||
7. เขียน integration test สำหรับ upload/download version flow (รัน MinIO + Keycloak จริงผ่าน testcontainers หรือ docker-compose ตอนเทส, ใช้ Keycloak admin API สร้าง test user + ขอ token มาแนบ request)
|
||||
8. เขียน `docker-compose.yml` สำหรับ local dev: MinIO + Keycloak (import realm-export.json อัตโนมัติ) + DB container + backend
|
||||
9. เขียน README อธิบาย local dev setup (sideload add-in, run backend, connection string, เข้า MinIO console, เข้า Keycloak admin console, สร้าง test user แรก)
|
||||
10. (v2) Implement `TemplatesController` + entities `Template`/`TemplateField`/`DocumentFieldValue` + migrations (FR-9) รวม parse SDT tags ตอน upload
|
||||
11. (v2) Implement `DocxGenerationService` + `POST /api/documents/generate` (FR-10) — แทนที่ SDT ที่ run level + stamp custom properties + สร้าง Document + v1 + field values ใน flow เดียว
|
||||
12. (v2) ขยาย upload endpoint ให้รับ `fields` แล้ว sync `DocumentFieldValue` + snapshot ต่อ version ตาม missing-field policy (FR-11)
|
||||
13. (v2) เขียน integration test ครอบ template detect / generate / field sync (รวมกรณี field หาย → ค่าเดิม + mark missing)
|
||||
|
||||
### Web Portal (เพิ่มเติม v2)
|
||||
1. Scaffold Nuxt.js + TailwindCSS — dev port 5173, `darkMode: 'class'`, `useTheme` (light/dark toggle + persist + system default)
|
||||
2. `useAuth` (PKCE browser redirect ผ่าน Keycloak client `word-versioning-web`) + `useApi` (Bearer + silent refresh)
|
||||
3. หน้าจัดการ template: list / upload (โชว์ fields ที่ detect อัตโนมัติ) / แก้ field definitions / deactivate
|
||||
4. หน้าสร้างเอกสาร: dynamic form สร้างจาก field definitions (input type ตาม field type) + validate + ดาวน์โหลดผลลัพธ์
|
||||
5. เขียน unit test ของ form validation + useTheme (Vitest)
|
||||
24
web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
75
web/README.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# Nuxt Minimal Starter
|
||||
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
23
web/app/app.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
const { initTheme, toggle, isDark } = useTheme()
|
||||
|
||||
onMounted(() => initTheme())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-900 dark:text-slate-100">
|
||||
<header class="flex items-center justify-between border-b border-slate-200 px-6 py-4 dark:border-slate-700">
|
||||
<NuxtLink to="/" class="text-lg font-semibold">Word Versioning</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
|
||||
@click="toggle"
|
||||
>
|
||||
{{ isDark ? '☀️ Light' : '🌙 Dark' }}
|
||||
</button>
|
||||
</header>
|
||||
<main class="mx-auto max-w-3xl p-6">
|
||||
<NuxtPage />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
3
web/app/assets/css/tailwind.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
36
web/app/composables/useTheme.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
export type Theme = 'light' | 'dark'
|
||||
|
||||
// ต้องตรงกับ inline script กัน FOUC ใน nuxt.config.ts
|
||||
const STORAGE_KEY = 'theme-preference'
|
||||
|
||||
export function useTheme() {
|
||||
const theme = useState<Theme>('theme', () => 'light')
|
||||
const isDark = computed(() => theme.value === 'dark')
|
||||
|
||||
function apply(next: Theme) {
|
||||
theme.value = next
|
||||
if (import.meta.client) {
|
||||
document.documentElement.classList.toggle('dark', next === 'dark')
|
||||
// ข้อยกเว้นเดียวที่ localStorage อนุญาต: theme preference ไม่ใช่ credential (กติกา #4)
|
||||
localStorage.setItem(STORAGE_KEY, next)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync state กับที่ inline script ตั้งไว้ตอนโหลดหน้า (client เท่านั้น) */
|
||||
function initTheme() {
|
||||
if (!import.meta.client) return
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'light' || stored === 'dark') {
|
||||
theme.value = stored
|
||||
} else {
|
||||
theme.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
document.documentElement.classList.toggle('dark', theme.value === 'dark')
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
apply(theme.value === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
return { theme, isDark, initTheme, toggle }
|
||||
}
|
||||
15
web/app/pages/index.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<section class="space-y-4">
|
||||
<h1 class="text-2xl font-bold">สร้างเอกสารจาก template</h1>
|
||||
<p class="text-slate-600 dark:text-slate-300">
|
||||
เลือก template กรอกค่า fields แล้วดาวน์โหลด .docx ที่เป็น version 1 พร้อม metadata
|
||||
ไปเปิดต่อใน Word พร้อม add-in
|
||||
</p>
|
||||
<!-- Phase 6: รายการ template ที่ active + ลิงก์เข้าหน้า documents/create/[templateId] -->
|
||||
<div
|
||||
class="rounded-lg border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 dark:border-slate-600 dark:text-slate-400"
|
||||
>
|
||||
รายการ template จะแสดงที่นี่ (Phase 6)
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
28
web/nuxt.config.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
|
||||
// ⚠️ Web portal ต้องใช้ port 5173 เท่านั้น — ห้ามใช้ 3000
|
||||
// (3000 ถูก add-in dev server ใช้อยู่: https://localhost:3000)
|
||||
devServer: {
|
||||
port: 5173,
|
||||
},
|
||||
|
||||
modules: ['@nuxtjs/tailwindcss'],
|
||||
|
||||
// ~/assets/css/tailwind.css ถูก @nuxtjs/tailwindcss pick up อัตโนมัติ
|
||||
|
||||
app: {
|
||||
head: {
|
||||
title: 'Word Versioning',
|
||||
// กัน dark-mode flash (FOUC) ก่อน Vue hydrate — ต้องใช้คีย์/ค่าเดียวกับ useTheme.ts
|
||||
script: [
|
||||
{
|
||||
innerHTML:
|
||||
"(function(){try{var t=localStorage.getItem('theme-preference');if(!t){t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}if(t==='dark'){document.documentElement.classList.add('dark');}}catch(e){}})();",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
11259
web/package-lock.json
generated
Normal file
18
web/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "web",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"nuxt": "^4.5.2",
|
||||
"vue": "^3.5.41",
|
||||
"vue-router": "^5.2.0"
|
||||
}
|
||||
}
|
||||
BIN
web/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
2
web/public/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
User-Agent: *
|
||||
Disallow:
|
||||
6
web/tailwind.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { Config } from 'tailwindcss'
|
||||
|
||||
export default {
|
||||
// dark mode คุมด้วย class บน <html> (toggle จาก useTheme)
|
||||
darkMode: 'class',
|
||||
} satisfies Config
|
||||
18
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.server.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.shared.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||