feat(wordaddin): port Word add-in taskpane from poc
พอร์ต taskpane ของ Word add-in "e-Court Documents Versioning" จาก D:\word-addin-poc มาเป็น sibling app ที่ route /docedit/wordaddin: - wordaddin/ (client-only zone): services พอร์ตดิบทั้งชุด (authService/versioningService เป็น mock รอ backend จริง) + models + config - components/wordaddin/: WordAddinGate ฉีด office.js self-host (singleton กัน StrictMode double-inject + fallback timer ครอบเคส onReady เงียบ) และ TaskpaneApp state machine แปลง JSX เป็น Genesis design system - scripts/fetch-office-js.mjs (postinstall ตัวที่สาม): ดาวน์โหลด Office.js runtime ล่าสุดจาก CDN Microsoft ไป public/wordaddin/office-js/ (gitignore สร้างใหม่ได้ — npm @microsoft/office-js ตกรุ่นไม่มี Document.close) - middleware/basePath/root page: เปิด /wordaddin เป็น public (auth แยกจาก แอป note), root /docedit redirect ไป taskpane ยกเว้นลิงก์ e-Court N8 (?fileUrl=&fileId=&typeId=) ที่ยังส่งต่อ /note ตามเดิม - docker/web.Dockerfile รัน fetch-office-js ตอน build + npm scripts dev:https สำหรับ sideload ตาม docs/WORDADDIN-DEV.md Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
7a146d48be
commit
c401f4deb7
37 changed files with 11216 additions and 17 deletions
13
wordaddin/config.ts
Normal file
13
wordaddin/config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Config ของ wordaddin — ค่าจาก NEXT_PUBLIC_* env (Next inline ตอน build)
|
||||
* (ต้นฉบับ D:\word-addin-poc\addin inject ผ่าน webpack DefinePlugin — พอร์ตมาเป็น env ของ Next)
|
||||
*
|
||||
* ช่วง mock: authService/versioningService เป็น mock ไม่ใช้ apiBaseUrl —
|
||||
* ตั้งค่านี้เมื่อปลด mock ต่อ backend จริง (ต้องเป็น HTTPS เสมอ — webview ของ Word บล็อก http)
|
||||
*/
|
||||
export const config = {
|
||||
/** base URL ของ backend API (ไม่มี slash ท้าย) — ว่าง = ยังไม่ต่อ backend จริง */
|
||||
apiBaseUrl: process.env.NEXT_PUBLIC_ADDIN_API_BASE_URL ?? "",
|
||||
/** ชื่อผู้ใช้ของ mock auth — แสดงหน้า header + เป็น createdByUserId ของ version ที่ mock สร้าง */
|
||||
mockUsername: process.env.NEXT_PUBLIC_ADDIN_MOCK_USERNAME ?? "tester01",
|
||||
};
|
||||
58
wordaddin/models/versionRecord.ts
Normal file
58
wordaddin/models/versionRecord.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* Type ตรงกับ response ของ backend (`Models/VersionDtos.cs` — ASP.NET Core serialize camelCase)
|
||||
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\models\versionRecord.ts
|
||||
*/
|
||||
|
||||
/** response ของ POST /api/documents/{documentId}/versions */
|
||||
export interface UploadVersionResult {
|
||||
hasPdf: boolean;
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
}
|
||||
|
||||
/** field เดียวใน snapshot ของ version (เอกสารที่ผูก template) */
|
||||
export interface FieldSnapshotEntry {
|
||||
value: string;
|
||||
missing: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* snapshot fields ต่อ version — backend เก็บ 2 รูปแบบ:
|
||||
* - เอกสารผูก template: `{ [fieldKey]: { value, missing } }`
|
||||
* - เอกสารไม่ผูก template (track เองจาก add-in): map ดิบ `{ [tag]: text }`
|
||||
* UI ต้อง handle ได้ทั้งคู่ (ใช้ readSnapshotField ช่วยอ่าน)
|
||||
*/
|
||||
export type FieldsSnapshot = Record<string, FieldSnapshotEntry> | Record<string, string>;
|
||||
|
||||
/** item ของ GET /api/documents/{documentId}/versions */
|
||||
export interface VersionRecord {
|
||||
hasPdf?: boolean;
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
/** ISO-8601 */
|
||||
createdAt: string;
|
||||
createdByUserId: string;
|
||||
/** SHA256 hex */
|
||||
contentHash: string;
|
||||
fields: FieldsSnapshot | null;
|
||||
/** คำอธิบาย version แบบ commit message ที่ผู้ใช้พิมพ์ตอนสร้าง (FR-13) — null ได้ */
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
/** อ่านค่า + สถานะ missing ของ field หนึ่งจาก snapshot ไม่ว่าจะเก็บรูปแบบไหน — ไม่มี field นี้คืน null */
|
||||
export function readSnapshotField(
|
||||
snapshot: FieldsSnapshot | null,
|
||||
key: string
|
||||
): FieldSnapshotEntry | null {
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
const raw = snapshot[key];
|
||||
if (raw === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof raw === "string") {
|
||||
return { value: raw, missing: false };
|
||||
}
|
||||
return { value: raw.value, missing: raw.missing };
|
||||
}
|
||||
122
wordaddin/services/apiClient.ts
Normal file
122
wordaddin/services/apiClient.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { config } from "../config";
|
||||
import * as authService from "./authService";
|
||||
|
||||
/**
|
||||
* fetch wrapper ของ add-in — แนบ Bearer ทุก request
|
||||
* โดน 401 → silent refresh แล้ว retry ครั้งเดียว; ยัง 401 → AuthRequiredError
|
||||
* (UI จับ error นี้แล้วพากลับหน้า sign-in อย่างเนียน — AC-9)
|
||||
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\apiClient.ts
|
||||
* (ช่วง mock ไม่มี caller จริง — เตรียมไว้ให้ versioningService วันปลด mock + AuthRequiredError
|
||||
* ถูก TaskpaneApp ใช้อยู่แล้ว)
|
||||
*/
|
||||
|
||||
/** บอกว่าต้องกลับไป login ใหม่ (ไม่มี token / refresh ไม่ได้ / 401 ซ้ำ) */
|
||||
export class AuthRequiredError extends Error {
|
||||
constructor(message = "เซสชันหมดอายุ — ต้อง login ใหม่") {
|
||||
super(message);
|
||||
this.name = "AuthRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
/** backend ตอบมาแต่ status ไม่สำเร็จ — message อ่านได้จาก body ถ้ามี */
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiRequestOptions {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: BodyInit | null;
|
||||
}
|
||||
|
||||
function toUrl(path: string): string {
|
||||
return path.startsWith("http") ? path : `${config.apiBaseUrl}${path}`;
|
||||
}
|
||||
|
||||
async function doFetch(
|
||||
path: string,
|
||||
options: ApiRequestOptions,
|
||||
accessToken: string
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(options.headers ?? {});
|
||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||
return fetch(toUrl(path), {
|
||||
method: options.method ?? "GET",
|
||||
headers,
|
||||
body: options.body,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* request พร้อม Bearer + retry-on-401 ครั้งเดียว
|
||||
* Response ที่ได้คืนไปให้ caller เช็ค ok เอง (ยกเว้น 401 ที่ refresh ไม่ช่วย → AuthRequiredError)
|
||||
*/
|
||||
export async function apiFetch(path: string, options: ApiRequestOptions = {}): Promise<Response> {
|
||||
const accessToken = await authService.getAccessToken();
|
||||
if (!accessToken) {
|
||||
throw new AuthRequiredError();
|
||||
}
|
||||
|
||||
let response = await doFetch(path, options, accessToken);
|
||||
if (response.status === 401) {
|
||||
const refreshedToken = await authService.refreshAccessToken();
|
||||
if (!refreshedToken) {
|
||||
throw new AuthRequiredError();
|
||||
}
|
||||
response = await doFetch(path, options, refreshedToken);
|
||||
if (response.status === 401) {
|
||||
throw new AuthRequiredError();
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/** อ่าน error message จาก response body — backend error ส่วนใหญ่เป็น plain text, บางทีเป็น JSON problem details */
|
||||
export async function readErrorMessage(response: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const text = (await response.text()).trim();
|
||||
if (!text) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown; title?: unknown };
|
||||
const detail = parsed.detail ?? parsed.message ?? parsed.title;
|
||||
if (typeof detail === "string" && detail) {
|
||||
return detail;
|
||||
}
|
||||
} catch {
|
||||
// plain text — ใช้ตรงๆ
|
||||
}
|
||||
return text;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/** GET แล้ว parse JSON — fail โยน ApiError พร้อม message จาก backend */
|
||||
export async function apiGetJson<T>(path: string, fallbackError: string): Promise<T> {
|
||||
const response = await apiFetch(path);
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, await readErrorMessage(response, fallbackError));
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
/** POST (form/json) แล้ว parse JSON — ใช้กับ upload version ที่ตอบ 201 */
|
||||
export async function apiSendExpectJson<T>(
|
||||
path: string,
|
||||
options: ApiRequestOptions,
|
||||
fallbackError: string
|
||||
): Promise<T> {
|
||||
const response = await apiFetch(path, options);
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, await readErrorMessage(response, fallbackError));
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
61
wordaddin/services/authService.ts
Normal file
61
wordaddin/services/authService.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* MOCK — แทน Keycloak OIDC/PKCE ของต้นฉบับ (ผู้ใช้งานเลือกไม่ใช้ Keycloak ช่วงพอร์ต)
|
||||
* ต้นฉบับ: D:\word-addin-poc\addin\src\services\authService.ts (login ผ่าน Office Dialog API,
|
||||
* token เก็บใน memory เท่านั้น, auto-refresh)
|
||||
*
|
||||
* วันสลับเป็น auth จริง: เขียน implementation ใหม่โดยคง export surface ด้านล่างครบทุกตัว
|
||||
* (เพิ่ม refreshAccessToken ที่ต้นฉบับมีอยู่แล้วสำหรับ apiClient) — โค้ดอื่นไม่ต้องแตะ
|
||||
*/
|
||||
|
||||
import { config } from "../config";
|
||||
|
||||
/** discriminated union เหมือนต้นฉบับ — ok:false ต้องมี error เสมอ */
|
||||
export type LoginResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
/** state เดียวของ mock — null = signed out */
|
||||
let username: string | null = null;
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** จำลองเปิดหน้าต่าง login — สำเร็จทันที ตั้งชื่อจาก NEXT_PUBLIC_ADDIN_MOCK_USERNAME */
|
||||
export async function login(): Promise<LoginResult> {
|
||||
await delay(300);
|
||||
username = config.mockUsername;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** mock-only — โหมด ?addindemo ใช้ตั้ง session ทันทีไม่ผ่านปุ่ม Sign in */
|
||||
export function signInForDemo(): void {
|
||||
username = config.mockUsername;
|
||||
}
|
||||
|
||||
export async function getAccessToken(): Promise<string | null> {
|
||||
return username ? `mock-token-${username}` : null;
|
||||
}
|
||||
|
||||
/** mock: token ไม่มีวันหมดอายุจริง — คืนตัวเดิมหรือ null ตามสถานะ login */
|
||||
export async function refreshAccessToken(): Promise<string | null> {
|
||||
return getAccessToken();
|
||||
}
|
||||
|
||||
export function getUsername(): string | undefined {
|
||||
return username ?? undefined;
|
||||
}
|
||||
|
||||
export function isTokenValid(): boolean {
|
||||
return username !== null;
|
||||
}
|
||||
|
||||
export function hasRefreshToken(): boolean {
|
||||
return username !== null;
|
||||
}
|
||||
|
||||
export function startAutoRefresh(): void {
|
||||
// mock: ไม่มีอะไรต้อง refresh
|
||||
}
|
||||
|
||||
export function signOut(): void {
|
||||
username = null;
|
||||
}
|
||||
208
wordaddin/services/autoSaveService.ts
Normal file
208
wordaddin/services/autoSaveService.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
/* global console */
|
||||
|
||||
/**
|
||||
* FR-17 — Auto Save: บันทึก local (`document.save`) เท่านั้น ห้ามสร้าง version อัตโนมัติ
|
||||
* (version เกิดจากปุ่ม FR-2 เท่านั้น — กัน spam history และรักษา concept ของ FR-13)
|
||||
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\autoSaveService.ts
|
||||
*
|
||||
* - debounce 3 วิ ผ่าน `notifyDocumentChanged()` (ยังไม่มี caller จริงใน Word — ต่อพ่วงไว้ให้ service อื่น)
|
||||
* - periodic ทุก 30 วิ: อ่าน `document.saved` ก่อน — dirty → ตั้ง flag FR-3 + save, สะอาด → ข้าม
|
||||
* - ช่วง save เดียวเท่านั้น (saving lock): มีเรียกซ้อน mark pending แล้วเก็บอีกรอบเมื่อรอบปัจจุบันจบ
|
||||
* - flow ที่ยุ่งกับไฟล์เอง (FR-2/FR-18) ใช้คู่ `suspend()`/`resume()` — ซ้อนกันได้ (นับ depth)
|
||||
* - เริ่มที่ Office.onReady (App mount) ไม่รอ sign-in · ล้ม → `onError` (banner ไม่บล็อก) ไม่ throw ทิ้ง
|
||||
*/
|
||||
|
||||
import { isDocumentDirty, saveDocument } from "./documentService";
|
||||
|
||||
export interface AutoSaveConfig {
|
||||
enabled: boolean;
|
||||
debounceMs: number;
|
||||
periodicSaveEnabled: boolean;
|
||||
periodicIntervalMs: number;
|
||||
}
|
||||
|
||||
/** TS constants ตาม spec §8 — แก้ที่นี่ที่เดียว (ไม่มี runtime config) */
|
||||
export const autoSaveConfig: AutoSaveConfig = {
|
||||
enabled: true,
|
||||
debounceMs: 3_000,
|
||||
periodicSaveEnabled: true,
|
||||
periodicIntervalMs: 30_000,
|
||||
};
|
||||
|
||||
class WordAutoSaveService {
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private periodicTimer: ReturnType<typeof setInterval> | undefined;
|
||||
private saving = false;
|
||||
private pendingSave = false;
|
||||
/** depth ของ suspend() — จะรัน timer ใหม่เฉพาะเมื่อ resume ครบทุกชั้น */
|
||||
private suspendDepth = 0;
|
||||
private idleWaiters: Array<() => void> = [];
|
||||
private started = false;
|
||||
private uncommitted = false;
|
||||
|
||||
/** auto-save ล้ม (timer-driven) — App ต่อเป็น banner เตือนแบบไม่บล็อก */
|
||||
onError: ((error: unknown) => void) | undefined;
|
||||
/** save สำเร็จหนึ่งรอบ — App ใช้เคลียร์ banner error เดิม */
|
||||
onSaved: (() => void) | undefined;
|
||||
/** state เปลี่ยน (isSaving / hasUncommittedSinceVersion) — App sync React state */
|
||||
onStateChange: (() => void) | undefined;
|
||||
|
||||
get isSaving(): boolean {
|
||||
return this.saving;
|
||||
}
|
||||
|
||||
/** FR-3 — เคยเห็น dirty หลัง version ล่าสุด (เคลียร์เมื่อสร้าง version สำเร็จ) */
|
||||
get hasUncommittedSinceVersion(): boolean {
|
||||
return this.uncommitted;
|
||||
}
|
||||
|
||||
/** FR-17 §16 — เริ่มตอน Office.onReady ได้เลย ไม่ต้องรอ sign-in */
|
||||
start(): void {
|
||||
if (!autoSaveConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
this.started = true;
|
||||
this.startTimers();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.started = false;
|
||||
this.stopTimers();
|
||||
}
|
||||
|
||||
/** flow ที่จัดการ save เอง (FR-2/FR-18) — หยุด timer + รอ save ค้างจบ แล้วค่อยทำงานต่อ */
|
||||
async suspend(): Promise<void> {
|
||||
this.suspendDepth++;
|
||||
this.stopTimers();
|
||||
await this.waitIdle();
|
||||
}
|
||||
|
||||
/** คู่ของ suspend() — ซ้อนกี่ชั้นต้อง resume ครบก่อน timer ถึงจะกลับมา */
|
||||
resume(): void {
|
||||
if (this.suspendDepth > 0) {
|
||||
this.suspendDepth--;
|
||||
}
|
||||
if (this.suspendDepth === 0) {
|
||||
this.startTimers();
|
||||
}
|
||||
}
|
||||
|
||||
/** FR-17 debounce — service ที่แก้เอกสารเรียกหลัง operation สำเร็จ */
|
||||
notifyDocumentChanged(): void {
|
||||
if (!autoSaveConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
this.clearDebounce();
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.debounceTimer = undefined;
|
||||
this.save().catch((error) => this.reportError(error));
|
||||
}, autoSaveConfig.debounceMs);
|
||||
}
|
||||
|
||||
/** บันทึกเดี๋ยวนี้ (ยกเลิก debounce ค้าง) — ถ้ามี save กำลังทำงานจะ mark pending แทน (coalesce) */
|
||||
async save(): Promise<void> {
|
||||
this.clearDebounce();
|
||||
if (this.saving) {
|
||||
this.pendingSave = true;
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.notifyState();
|
||||
try {
|
||||
await saveDocument();
|
||||
if (this.pendingSave) {
|
||||
this.pendingSave = false;
|
||||
await saveDocument();
|
||||
}
|
||||
this.onSaved?.();
|
||||
} finally {
|
||||
this.pendingSave = false;
|
||||
this.saving = false;
|
||||
this.notifyState();
|
||||
this.resolveIdle();
|
||||
}
|
||||
}
|
||||
|
||||
/** บันทึกให้เสร็จก่อนกลับ — ใช้ก่อน close/upload (spec auto-save ข้อ 12) เรียกหลัง suspend() เสมอ */
|
||||
async forceSave(): Promise<void> {
|
||||
this.clearDebounce();
|
||||
await this.waitIdle();
|
||||
await this.save();
|
||||
await this.waitIdle();
|
||||
}
|
||||
|
||||
/** FR-3 — version ใหม่ถูกสร้างสำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version" */
|
||||
clearUncommittedSinceVersion(): void {
|
||||
this.uncommitted = false;
|
||||
this.notifyState();
|
||||
}
|
||||
|
||||
private startTimers(): void {
|
||||
if (!this.started || !autoSaveConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
if (autoSaveConfig.periodicSaveEnabled && !this.periodicTimer) {
|
||||
this.periodicTimer = setInterval(() => {
|
||||
void this.periodicTick();
|
||||
}, autoSaveConfig.periodicIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
private stopTimers(): void {
|
||||
this.clearDebounce();
|
||||
if (this.periodicTimer) {
|
||||
clearInterval(this.periodicTimer);
|
||||
this.periodicTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private clearDebounce(): void {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async periodicTick(): Promise<void> {
|
||||
if (this.saving || this.suspendDepth > 0) {
|
||||
return; // มี save หรือ flow อื่นถือไม้ตายอยู่ — รอรอบหน้า
|
||||
}
|
||||
try {
|
||||
// FR-3 — periodic ทำหน้าที่ reminder ด้วย: เห็น dirty ก่อน save ตั้ง flag
|
||||
// (ระบบ reminder เดิมที่ poll แยกถูกแทนด้วย tick นี้)
|
||||
if (!(await isDocumentDirty())) {
|
||||
return;
|
||||
}
|
||||
this.uncommitted = true;
|
||||
this.notifyState();
|
||||
await this.save();
|
||||
} catch (error) {
|
||||
this.reportError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private waitIdle(): Promise<void> {
|
||||
return this.saving
|
||||
? new Promise<void>((resolve) => this.idleWaiters.push(resolve))
|
||||
: Promise.resolve();
|
||||
}
|
||||
|
||||
private resolveIdle(): void {
|
||||
const waiters = this.idleWaiters;
|
||||
this.idleWaiters = [];
|
||||
for (const waiter of waiters) {
|
||||
waiter();
|
||||
}
|
||||
}
|
||||
|
||||
private notifyState(): void {
|
||||
this.onStateChange?.();
|
||||
}
|
||||
|
||||
private reportError(error: unknown): void {
|
||||
console.warn("auto-save failed:", error);
|
||||
this.onError?.(error);
|
||||
}
|
||||
}
|
||||
|
||||
export const autoSaveService = new WordAutoSaveService();
|
||||
86
wordaddin/services/documentService.ts
Normal file
86
wordaddin/services/documentService.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/* global Word, Office */
|
||||
|
||||
/**
|
||||
* FR-17 / FR-18 — จุดเดียวที่คุยกับ document-level API ของ Word
|
||||
* (native save / อ่าน dirty / ปิดเอกสาร) แยกจาก versionFlow เพื่อให้
|
||||
* autoSaveService กับปุ่ม [บันทึก] ใช้ร่วมได้โดยไม่ต้องพ่วง flow versioning
|
||||
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\documentService.ts
|
||||
*/
|
||||
|
||||
export enum DocumentCloseMode {
|
||||
/** บันทึกการแก้ไขก่อนปิด */
|
||||
Save = "Save",
|
||||
/** ปิดโดยไม่บันทึก — ไว้ใช้ระดับ service เท่านั้น (UI ปัจจุบันไม่มีปุ่มไหนใช้) */
|
||||
SkipSave = "SkipSave",
|
||||
}
|
||||
|
||||
/** native save ตาม FR-2 ขั้นตอนที่ 4 — ใช้ร่วมทั้ง versionFlow และ autoSaveService
|
||||
* เรียกแบบไม่ส่ง saveBehavior: ปลอดภัยทุกรุ่น runtime — รุ่นเก่า (npm 1.1.110) ไม่มี
|
||||
* enum Word.SaveBehavior และรับ save() แบบไม่มี argument, รุ่นใหม่ param เป็น optional
|
||||
* ที่ default เดียวกัน (behavior = save เงียบ ๆ) */
|
||||
export async function saveDocument(): Promise<void> {
|
||||
await Word.run(async (context) => {
|
||||
context.document.save();
|
||||
await context.sync();
|
||||
});
|
||||
}
|
||||
|
||||
/** FR-3 — มีการแก้ไขที่ยังไม่ถูกบันทึกลงไฟล์ local หรือไม่ */
|
||||
export async function isDocumentDirty(): Promise<boolean> {
|
||||
return Word.run(async (context) => {
|
||||
context.document.load("saved");
|
||||
await context.sync();
|
||||
return !context.document.saved;
|
||||
});
|
||||
}
|
||||
|
||||
/** save แล้วเช็คว่า Word เห็นไฟล์สะอาดจริง — บางสถานะ (เช่น หลังเขียน custom properties /
|
||||
* custom XML part ของ flow versioning) Word ยังรายงาน saved=false แม้ save ผ่านแล้ว
|
||||
* จึง save ซ้ำแบบมีขอบเขต (รวมสูงสุด 3 รอบ) — ไม่งั้น periodic tick ของ auto-save
|
||||
* เห็น dirty ตอนไม่มีใครแก้อะไร แล้ว banner FR-3 ("มีการแก้ไขที่ยังไม่เป็น version")
|
||||
* โผล่ผิด ๆ หลังจบ flow */
|
||||
export async function saveDocumentVerified(): Promise<void> {
|
||||
await saveDocument();
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
if (!(await isDocumentDirty())) return;
|
||||
await saveDocument();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FR-18 — `document.close()` ต้องการ WordApi 1.5 **และใช้ไม่ได้บน Word on the web**
|
||||
* (requirement-set table ระบุรองรับ แต่หน้าเอกสารของ method เขียนไว้ว่า "isn't
|
||||
* supported in Word on the web") จึงต้องเช็ค platform คู่กันเสมอ —
|
||||
* UI ใช้ซ่อนปุ่ม close ทุกแบบ, service ใช้ guard ก่อนเรียกจริง
|
||||
* (เรียกนอก Word — เช่น SSR หรือ browser ธรรมดา — throw ถูก catch คืน false เสมอ)
|
||||
*/
|
||||
export function canCloseDocument(): boolean {
|
||||
try {
|
||||
return (
|
||||
Office.context.requirements.isSetSupported("WordApi", "1.5") &&
|
||||
Office.context.platform !== Office.PlatformType.OfficeOnline
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** FR-18 — ปิดเอกสารปัจจุบัน (task pane จะปิดตามเอกสาร) */
|
||||
export async function closeDocument(mode: DocumentCloseMode = DocumentCloseMode.Save): Promise<void> {
|
||||
if (!canCloseDocument()) {
|
||||
throw new Error(
|
||||
"เวอร์ชัน Word นี้ไม่รองรับการปิดเอกสารจาก add-in (รองรับเฉพาะ Word Desktop ที่ใช้ WordApi 1.5 ได้)"
|
||||
);
|
||||
}
|
||||
await Word.run(async (context) => {
|
||||
// ส่งค่า underlying ของ enum เป็น string ตรง ๆ ไม่อ่าน Word.CloseBehavior.* —
|
||||
// runtime self-host อาจเป็นรุ่นเก่ากว่า @types (กรณี npm 1.1.110 เดิม: ไม่มี enum นี้เลย
|
||||
// อ่านแล้ว TypeError "reading 'save'"; ปัจจุบัน fetch ฉบับ CDN ล่าสุดมี enum แล้วแต่ค่า
|
||||
// string ทำงานเหมือนกันทุกรุ่น — cast กัน @types ที่พิมพ์ parameter เป็น enum object)
|
||||
context.document.close(
|
||||
(mode === DocumentCloseMode.Save ? "Save" : "SkipSave") as Word.CloseBehavior
|
||||
);
|
||||
// ห้ามเรียก context.sync() หลัง close() — เอกสารปิดกลาง batch ทำให้ sync ถูก abort
|
||||
// (ตัวอย่างทางการของ Word เรียก close เฉยๆ แล้วพึ่ง implicit sync ตอน run() จบ)
|
||||
});
|
||||
}
|
||||
10
wordaddin/services/errors.ts
Normal file
10
wordaddin/services/errors.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/** ข้อความ error ที่อ่านได้สำหรับแสดงใน task pane — ไม่เคย throw (กติกา: ทุก Office.js/API call ต้อง catch) */
|
||||
export function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string" && error) {
|
||||
return error;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
128
wordaddin/services/fileService.ts
Normal file
128
wordaddin/services/fileService.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/* global Office, console */
|
||||
|
||||
/**
|
||||
* ดึงไฟล์ .docx ปัจจุบันผ่าน File/Slice API (FR-2 ขั้นตอนที่ 5)
|
||||
* กติกา: Word เปิด File object พร้อมกันได้แค่ 2 ตัว → เรียก closeAsync เสมอใน finally
|
||||
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\fileService.ts
|
||||
*/
|
||||
|
||||
export const DOCX_CONTENT_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
|
||||
/** ขนาด slice ต่อครั้ง — 1MB กันกิน memory ยักษ์ในเครื่องรายงานน้อย */
|
||||
export const PDF_CONTENT_TYPE = "application/pdf";
|
||||
|
||||
const SLICE_SIZE = 1024 * 1024;
|
||||
|
||||
function officeError(result: Office.AsyncResult<unknown>, fallback: string): Error {
|
||||
return new Error(result.error?.message || fallback);
|
||||
}
|
||||
|
||||
function getFileAsync(fileType: Office.FileType): Promise<Office.File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
Office.context.document.getFileAsync(fileType, { sliceSize: SLICE_SIZE }, (result) => {
|
||||
if (result.status === Office.AsyncResultStatus.Succeeded) {
|
||||
resolve(result.value);
|
||||
} else {
|
||||
reject(officeError(result, "เปิดไฟล์เอกสารไม่สำเร็จ"));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error("เปิดไฟล์เอกสารไม่สำเร็จ"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* host แต่ละตัวส่งข้อมูล slice มาคนละรูปแบบ (เอกสาร Office.js ระบุแค่ "byte array"):
|
||||
* - Word บน Windows ส่วนใหญ่ = ArrayBuffer / typed-array view
|
||||
* - Word บน Mac = base64 string หรือ byte array ธรรมดา (number[])
|
||||
* ปรับเป็น Uint8Array ให้หมดก่อนประกอบเป็น Blob
|
||||
*/
|
||||
export function sliceDataToBytes(data: unknown): Uint8Array<ArrayBuffer> {
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return new Uint8Array(data);
|
||||
}
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
const view = data as ArrayBufferView;
|
||||
return new Uint8Array(view.buffer as ArrayBuffer, view.byteOffset, view.byteLength);
|
||||
}
|
||||
if (typeof data === "string") {
|
||||
// base64 (บาง host ใส่ data-url prefix หรือขึ้นบรรทัดมาด้วย — ตัดทิ้งก่อน decode)
|
||||
const base64 = data.replace(/^data:[^,]*?;base64,/, "").replace(/\s/g, "");
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index++) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
if (Array.isArray(data) && (data.length === 0 || typeof data[0] === "number")) {
|
||||
return Uint8Array.from(data);
|
||||
}
|
||||
throw new Error(`รูปแบบข้อมูลของ slice ไม่รองรับ (ได้รับ ${typeof data})`);
|
||||
}
|
||||
|
||||
/** อ่าน slice หนึ่งชิ้นแล้ว normalize เป็น Uint8Array */
|
||||
function getSliceAsync(file: Office.File, sliceIndex: number): Promise<BlobPart> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
file.getSliceAsync(sliceIndex, (result) => {
|
||||
if (result.status === Office.AsyncResultStatus.Succeeded) {
|
||||
try {
|
||||
resolve(sliceDataToBytes(result.value.data));
|
||||
} catch (error) {
|
||||
reject(
|
||||
error instanceof Error ? error : new Error(`อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
reject(officeError(result, `อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(`อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** ปิด File object — fail ก็แค่ warn ไม่ทับ error ของ flow หลัก */
|
||||
function closeFileQuietly(file: Office.File): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
file.closeAsync(() => resolve());
|
||||
} catch (error) {
|
||||
console.warn("closeAsync failed:", error);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ประกอบไฟล์ทั้งไฟล์เป็น Blob — อ่าน slice ทีละชิ้นตามลำดับ
|
||||
* เรียก closeAsync เสมอ (สำเร็จหรือ fail ก็ตาม)
|
||||
*/
|
||||
async function readFileAsBlob(fileType: Office.FileType, contentType: string): Promise<Blob> {
|
||||
const file = await getFileAsync(fileType);
|
||||
try {
|
||||
const chunks: BlobPart[] = [];
|
||||
for (let index = 0; index < file.sliceCount; index++) {
|
||||
chunks.push(await getSliceAsync(file, index));
|
||||
}
|
||||
return new Blob(chunks, { type: contentType });
|
||||
} finally {
|
||||
await closeFileQuietly(file);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentDocumentFile(): Promise<Blob> {
|
||||
return readFileAsBlob(Office.FileType.Compressed, DOCX_CONTENT_TYPE);
|
||||
}
|
||||
|
||||
export async function getCurrentDocumentPdf(): Promise<Blob> {
|
||||
if (!Office.context.requirements.isSetSupported("PdfFile", "1.1")) {
|
||||
throw new Error("Word นี้ไม่รองรับการสร้าง PDF");
|
||||
}
|
||||
return readFileAsBlob(Office.FileType.Pdf, PDF_CONTENT_TYPE);
|
||||
}
|
||||
242
wordaddin/services/metadataService.ts
Normal file
242
wordaddin/services/metadataService.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/* global Word, console */
|
||||
|
||||
/**
|
||||
* Metadata ในไฟล์ .docx ที่ add-in อ่าน/เขียน (spec ส่วน 8):
|
||||
* - Custom Document Properties = primary flag ตรวจจับ tracked document
|
||||
* - Custom XML Part (ns `urn:myaddin:versioning`) = secondary เท่านั้น — backend คือ source of truth
|
||||
* - readContentControlFields (FR-11) อ่านค่า content controls เป็น map tag→text
|
||||
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\metadataService.ts
|
||||
*
|
||||
* กติกา: ทุก Word.run ต้องจบด้วย context.sync(); XML part ฝั่งเขียน **ห้าม throw**
|
||||
* (known bug บน Mac บาง build — เขียนไม่ได้ก็ปล่อยผ่าน)
|
||||
*/
|
||||
|
||||
/** GUID คงที่ของ add-in — ต้องตรงเป๊ะกับ `AddInMetadata.AddInSignatureGuid` ฝั่ง backend (spec ส่วน 8) */
|
||||
export const ADDIN_SIGNATURE_GUID = "9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D";
|
||||
|
||||
export const CUSTOM_XML_NAMESPACE = "urn:myaddin:versioning";
|
||||
|
||||
export const SAVED_BY_ADD_IN_PROPERTY = "SavedByAddIn";
|
||||
export const DOCUMENT_TRACKING_ID_PROPERTY = "DocumentTrackingId";
|
||||
export const LAST_VERSION_ID_PROPERTY = "LastVersionId";
|
||||
export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp";
|
||||
/** FR-16 — stamp เฉพาะไฟล์ร่าง (draft) ที่เว็บสร้าง: template ต้นทาง (ค่าเดียวกับ AddInMetadata.TemplateIdPropertyName ฝั่ง backend) */
|
||||
export const TEMPLATE_ID_PROPERTY = "TemplateId";
|
||||
|
||||
/** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */
|
||||
export interface TrackedDocumentProperties {
|
||||
savedByAddIn?: string;
|
||||
documentTrackingId?: string;
|
||||
lastVersionId?: string;
|
||||
lastVersionTimestamp?: string;
|
||||
/** มีเฉพาะไฟล์ร่าง (FR-16) — ส่งต่อให้ backend ตอน Start Versioning เพื่อผูก Document กับ template */
|
||||
templateId?: string;
|
||||
}
|
||||
|
||||
/** item ใน Custom XML Part `<VersionHistory>` */
|
||||
export interface XmlVersionEntry {
|
||||
id: string;
|
||||
number: number;
|
||||
timestamp: string;
|
||||
user: string;
|
||||
}
|
||||
|
||||
/** อ่าน custom properties ทั้ง 5 ตัวที่ระบบสนใจ (4 ตัวของ tracked + TemplateId ของไฟล์ร่าง FR-16) */
|
||||
export async function readTrackedProperties(): Promise<TrackedDocumentProperties> {
|
||||
return Word.run(async (context) => {
|
||||
const properties = context.document.properties.customProperties;
|
||||
const savedByAddIn = properties.getItemOrNullObject(SAVED_BY_ADD_IN_PROPERTY);
|
||||
const documentTrackingId = properties.getItemOrNullObject(DOCUMENT_TRACKING_ID_PROPERTY);
|
||||
const lastVersionId = properties.getItemOrNullObject(LAST_VERSION_ID_PROPERTY);
|
||||
const lastVersionTimestamp = properties.getItemOrNullObject(LAST_VERSION_TIMESTAMP_PROPERTY);
|
||||
const templateId = properties.getItemOrNullObject(TEMPLATE_ID_PROPERTY);
|
||||
// load "value" ของ null-object proxy ก็ทำให้ isNullObject แม่นหลัง sync
|
||||
savedByAddIn.load("value");
|
||||
documentTrackingId.load("value");
|
||||
lastVersionId.load("value");
|
||||
lastVersionTimestamp.load("value");
|
||||
templateId.load("value");
|
||||
await context.sync();
|
||||
|
||||
const read = (property: Word.CustomProperty): string | undefined =>
|
||||
property.isNullObject || property.value === undefined || property.value === null
|
||||
? undefined
|
||||
: String(property.value);
|
||||
|
||||
return {
|
||||
savedByAddIn: read(savedByAddIn),
|
||||
documentTrackingId: read(documentTrackingId),
|
||||
lastVersionId: read(lastVersionId),
|
||||
lastVersionTimestamp: read(lastVersionTimestamp),
|
||||
templateId: read(templateId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** FR-1 — ไฟล์นี้ผ่าน add-in นี้หรือเปล่า (รวมไฟล์ที่ generate จากเว็บ ที่ backend stamp ไว้แล้ว)
|
||||
* หมายเหตุ: TaskpaneApp.detect() ทำนายค่านี้ inline จาก readTrackedProperties() อยู่แล้ว (ต้องอ่าน properties
|
||||
* ครั้งเดียวทั้งชุดเพื่อให้ไฟล์ร่าง untracked ได้อ่าน TemplateId ด้วย — FR-16) ฟังก์ชันนี้จึงเหลือเป็น
|
||||
* service API ที่มี test คุม semantics ของ FR-1 ไว้ */
|
||||
export async function isTrackedDocument(): Promise<boolean> {
|
||||
const properties = await readTrackedProperties();
|
||||
return properties.savedByAddIn === ADDIN_SIGNATURE_GUID;
|
||||
}
|
||||
|
||||
export interface StampPropertiesInput {
|
||||
/** tracking id ของเอกสาร — ส่งเฉพาะตอนสร้างใหม่ (Start Versioning) หรือตอน stamp รอบแรก */
|
||||
documentTrackingId?: string;
|
||||
lastVersionId?: string;
|
||||
lastVersionTimestamp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* เขียน/stamp custom properties — ใช้ add() ทับของเดิม (add กับ key ที่มีอยู่ = update ค่า
|
||||
* ต่างจาก XML part ที่ต้อง delete ก่อน) พร้อมกันทั้งชุดใน Word.run เดียว
|
||||
*/
|
||||
export async function stampTrackedProperties(input: StampPropertiesInput): Promise<void> {
|
||||
await Word.run(async (context) => {
|
||||
const properties = context.document.properties.customProperties;
|
||||
properties.add(SAVED_BY_ADD_IN_PROPERTY, ADDIN_SIGNATURE_GUID);
|
||||
if (input.documentTrackingId !== undefined) {
|
||||
properties.add(DOCUMENT_TRACKING_ID_PROPERTY, input.documentTrackingId);
|
||||
}
|
||||
if (input.lastVersionId !== undefined) {
|
||||
properties.add(LAST_VERSION_ID_PROPERTY, input.lastVersionId);
|
||||
}
|
||||
if (input.lastVersionTimestamp !== undefined) {
|
||||
properties.add(LAST_VERSION_TIMESTAMP_PROPERTY, input.lastVersionTimestamp);
|
||||
}
|
||||
await context.sync();
|
||||
});
|
||||
}
|
||||
|
||||
/** GUID ใหม่สำหรับ tracking id / clientVersionId */
|
||||
export function generateGuid(): string {
|
||||
if (typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom XML Part — secondary history (ไม่ใช่ source of truth)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function escapeXmlAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** สร้าง XML ของ `<VersionHistory>` ตามรูปใน spec ส่วน 8 */
|
||||
export function buildVersionHistoryXml(entries: XmlVersionEntry[]): string {
|
||||
const elements = entries
|
||||
.map(
|
||||
(entry) =>
|
||||
` <Version id="${escapeXmlAttribute(entry.id)}" number="${entry.number}" timestamp="${escapeXmlAttribute(
|
||||
entry.timestamp
|
||||
)}" user="${escapeXmlAttribute(entry.user)}"/>`
|
||||
)
|
||||
.join("\n");
|
||||
return `<VersionHistory xmlns="${CUSTOM_XML_NAMESPACE}">\n${elements}\n</VersionHistory>`;
|
||||
}
|
||||
|
||||
function parseVersionHistoryXml(xml: string): XmlVersionEntry[] {
|
||||
const doc = new DOMParser().parseFromString(xml, "application/xml");
|
||||
const nodes = Array.from(doc.getElementsByTagNameNS(CUSTOM_XML_NAMESPACE, "Version"));
|
||||
return nodes
|
||||
.map((node) => ({
|
||||
id: node.getAttribute("id") ?? "",
|
||||
number: Number(node.getAttribute("number") ?? Number.NaN),
|
||||
timestamp: node.getAttribute("timestamp") ?? "",
|
||||
user: node.getAttribute("user") ?? "",
|
||||
}))
|
||||
.filter((entry) => entry.id !== "" && Number.isFinite(entry.number));
|
||||
}
|
||||
|
||||
/** อ่าน compact history จาก XML part — fail อ่านเป็น [] เสมอ (ไม่ throw) */
|
||||
export async function readVersionHistoryFromXml(): Promise<XmlVersionEntry[]> {
|
||||
try {
|
||||
return await Word.run(async (context) => {
|
||||
const scoped = context.document.customXmlParts.getByNamespace(CUSTOM_XML_NAMESPACE);
|
||||
context.load(scoped);
|
||||
await context.sync();
|
||||
const xmlResults = scoped.items.map((part) => part.getXml());
|
||||
await context.sync();
|
||||
|
||||
const entries: XmlVersionEntry[] = [];
|
||||
for (const result of xmlResults) {
|
||||
entries.push(...parseVersionHistoryXml(result.value));
|
||||
}
|
||||
entries.sort((a, b) => b.number - a.number); // ใหม่ → เก่า
|
||||
return entries;
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("readVersionHistoryFromXml failed (secondary storage — ปล่อยผ่าน):", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* เขียน XML part ด้วย pattern delete-then-add (แก้ part เดิมตรงๆ ไม่ได้)
|
||||
* ⚠️ ห้าม throw แม้จะล้มเหลว — known bug บน Word for Mac บาง build
|
||||
* คืน true = เขียนสำเร็จ, false = fail แบบ silent (backend ยังเป็น source of truth อยู่ดี — AC-10)
|
||||
*/
|
||||
export async function writeVersionHistoryToXml(entries: XmlVersionEntry[]): Promise<boolean> {
|
||||
try {
|
||||
await Word.run(async (context) => {
|
||||
// 1) ลบ part เดิมใน namespace นี้ทั้งหมดก่อน
|
||||
const scoped = context.document.customXmlParts.getByNamespace(CUSTOM_XML_NAMESPACE);
|
||||
context.load(scoped);
|
||||
await context.sync();
|
||||
for (const part of scoped.items) {
|
||||
part.delete();
|
||||
}
|
||||
if (scoped.items.length > 0) {
|
||||
await context.sync();
|
||||
}
|
||||
// 2) add ของใหม่
|
||||
context.document.customXmlParts.add(buildVersionHistoryXml(entries));
|
||||
await context.sync();
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("writeVersionHistoryToXml failed (silent per spec — known Mac bug):", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content controls → fields (FR-11)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* อ่าน content controls ทั้งเอกสารเป็น map `{ [tag]: text }` สำหรับแนบกับ upload version
|
||||
* - ไม่มี content controls / tag ว่าง → ข้าม (คืน {} ไม่ error)
|
||||
* - tag ซ้ำ → เอาตัวแรกตามลำดับในเอกสาร
|
||||
*/
|
||||
export async function readContentControlFields(): Promise<Record<string, string>> {
|
||||
return Word.run(async (context) => {
|
||||
const controls = context.document.contentControls;
|
||||
controls.load("tag, text");
|
||||
await context.sync();
|
||||
|
||||
const fields: Record<string, string> = {};
|
||||
for (const control of controls.items) {
|
||||
const tag = (control.tag ?? "").trim();
|
||||
if (tag === "") {
|
||||
continue;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(fields, tag)) {
|
||||
fields[tag] = control.text;
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
});
|
||||
}
|
||||
229
wordaddin/services/versionFlow.ts
Normal file
229
wordaddin/services/versionFlow.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/* global Office, console */
|
||||
|
||||
/**
|
||||
* Orchestration ของ flow หลักสองอันของ add-in (แยกจาก UI เพื่อ unit test ได้):
|
||||
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\versionFlow.ts
|
||||
*
|
||||
* - `startVersioning` (FR-1 "Start Versioning This Document") — เอกสารใหม่ที่ยังไม่ถูก track
|
||||
* - `saveAndVersion` (FR-2) — ไล่ตามลำดับใน spec แบบเคร่งครัด:
|
||||
* stamp properties → XML part (delete-then-add, fail ได้เงียบๆ) → document.save()
|
||||
* → ดึงไฟล์ + extract fields (FR-11) → POST version → คืนผลให้ UI แสดง/refresh history
|
||||
*
|
||||
* หมายเหตุค่า id ใน metadata: `LastVersionId` และ `<Version id>` ใน XML part เก็บ
|
||||
* **clientVersionId** (GUID ที่ client generate) เพราะต้อง stamp ลงไฟล์ *ก่อน* upload —
|
||||
* versionId ของ backend จะได้มาหลัง upload ซึ่งไฟล์ถูก capture ไปแล้ว
|
||||
* (backend คือ source of truth ของ id จริงอยู่ดี)
|
||||
*/
|
||||
|
||||
import * as authService from "./authService";
|
||||
import { autoSaveService } from "./autoSaveService";
|
||||
import { DocumentCloseMode, closeDocument, saveDocumentVerified } from "./documentService";
|
||||
import { getCurrentDocumentFile, getCurrentDocumentPdf } from "./fileService";
|
||||
import {
|
||||
generateGuid,
|
||||
readContentControlFields,
|
||||
readVersionHistoryFromXml,
|
||||
stampTrackedProperties,
|
||||
writeVersionHistoryToXml,
|
||||
XmlVersionEntry,
|
||||
} from "./metadataService";
|
||||
import { uploadVersion } from "./versioningService";
|
||||
|
||||
export interface StartVersioningResult {
|
||||
hasPdf: boolean;
|
||||
documentId: string;
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
}
|
||||
|
||||
export interface SaveAndVersionResult {
|
||||
hasPdf: boolean;
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* FR-18 — version ถูกบันทึกสำเร็จแล้วแต่ปิดเอกสารไม่สำเร็จ (เอกสารยังเปิดอยู่)
|
||||
* UI ต้องแยกสถานะนี้ออกจาก "สร้าง version ไม่สำเร็จ" — version ปลอดภัยบน backend แล้ว
|
||||
*/
|
||||
export class VersionSavedButCloseFailedError extends Error {
|
||||
constructor(
|
||||
public readonly result: SaveAndVersionResult,
|
||||
cause: unknown
|
||||
) {
|
||||
super(
|
||||
`บันทึก version ${result.versionNumber} เรียบร้อย แต่ปิดเอกสารไม่สำเร็จ: ${
|
||||
cause instanceof Error ? cause.message : String(cause)
|
||||
} — ปิดเอกสารเองได้เลย`
|
||||
);
|
||||
this.name = "VersionSavedButCloseFailedError";
|
||||
}
|
||||
}
|
||||
|
||||
/** ชื่อไฟล์จาก URL ของ document ถ้าดึงได้ (desktop มักเป็น path จริง) — ใช้เป็นชื่อตอน upload */
|
||||
function guessDocumentFileName(): string | undefined {
|
||||
try {
|
||||
const url = Office.context.document.url;
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
const name = url.split(/[\\/]/).pop();
|
||||
if (!name) {
|
||||
return undefined;
|
||||
}
|
||||
return name.toLowerCase().endsWith(".docx") ? name : `${name}.docx`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** `context.document.save()` ย้ายไป documentService (ใช้ร่วมกับ FR-17 auto-save) */
|
||||
|
||||
async function uploadCurrentFile(
|
||||
documentId: string,
|
||||
clientVersionId: string,
|
||||
fields: Record<string, string>,
|
||||
message?: string,
|
||||
onProgress?: (progress: string) => void,
|
||||
templateId?: string
|
||||
): Promise<SaveAndVersionResult> {
|
||||
const file = await getCurrentDocumentFile();
|
||||
let pdf: Blob | undefined;
|
||||
onProgress?.("กำลังสร้างสำเนา PDF…");
|
||||
try {
|
||||
pdf = await getCurrentDocumentPdf();
|
||||
if (pdf.size === 0) throw new Error("Word คืนไฟล์ PDF ว่าง");
|
||||
} catch (error) {
|
||||
console.warn("PDF capture failed:", error);
|
||||
pdf = undefined;
|
||||
}
|
||||
onProgress?.("กำลังอัปโหลด version…");
|
||||
return uploadVersion(
|
||||
documentId,
|
||||
file,
|
||||
clientVersionId,
|
||||
fields,
|
||||
guessDocumentFileName(),
|
||||
message,
|
||||
pdf,
|
||||
templateId
|
||||
);
|
||||
}
|
||||
|
||||
/** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1
|
||||
* (FR-13: message คือ commit message ทางเลือกจาก dialog ยืนยัน)
|
||||
* (FR-16: templateId ส่งเฉพาะไฟล์ร่างที่เว็บสร้าง — backend ผูก Document กับ template ต้นทางตอน auto-create) */
|
||||
export async function startVersioning(
|
||||
message?: string,
|
||||
templateId?: string,
|
||||
onProgress?: (progress: string) => void
|
||||
): Promise<StartVersioningResult> {
|
||||
// FR-17 — หยุด auto-save ตลอด flow กัน save แทรกกลาง stamp/capture แล้วค่อนคืนตอนจบ
|
||||
await autoSaveService.suspend();
|
||||
try {
|
||||
const documentId = generateGuid();
|
||||
const clientVersionId = generateGuid();
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// 1) stamp รอบแรก — ได้ทั้ง SavedByAddIn และ DocumentTrackingId ใหม่
|
||||
onProgress?.("กำลังบันทึก metadata ของเอกสาร…");
|
||||
await stampTrackedProperties({
|
||||
documentTrackingId: documentId,
|
||||
lastVersionId: clientVersionId,
|
||||
lastVersionTimestamp: timestamp,
|
||||
});
|
||||
|
||||
// 2) XML part (secondary — fail ได้เงียบๆ)
|
||||
onProgress?.("กำลังอัปเดต version history ในไฟล์…");
|
||||
await writeVersionHistoryToXml([
|
||||
{ id: clientVersionId, number: 1, timestamp, user: authService.getUsername() ?? "unknown" },
|
||||
]);
|
||||
|
||||
// 3) native save แล้ว capture ไฟล์ + fields
|
||||
onProgress?.("กำลังบันทึกไฟล์…");
|
||||
await saveDocumentVerified();
|
||||
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
|
||||
const fields = await readContentControlFields();
|
||||
|
||||
// 4) upload version แรก (แนบ templateId เมื่อเป็นไฟล์ร่าง — FR-16)
|
||||
onProgress?.("กำลังอัปโหลด version 1…");
|
||||
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress, templateId);
|
||||
// FR-3 — สร้าง version สำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version"
|
||||
autoSaveService.clearUncommittedSinceVersion();
|
||||
return { documentId, ...result };
|
||||
} finally {
|
||||
autoSaveService.resume();
|
||||
}
|
||||
}
|
||||
|
||||
/** FR-2 — Save & Version ของเอกสารที่ track อยู่แล้ว (เรียกหลังผู้ใช้กดยืนยันใน dialog แล้ว)
|
||||
* (FR-13: message คือ commit message ทางเลือกจาก dialog ยืนยัน)
|
||||
* (FR-18: closeAfter=true — หลัง upload สำเร็จเท่านั้น ปิดเอกสารด้วย close(Save);
|
||||
* ปิดไม่สำเร็จโยน VersionSavedButCloseFailedError ไม่ใช่ error ของ version) */
|
||||
export async function saveAndVersion(
|
||||
documentId: string,
|
||||
latestVersionNumber: number,
|
||||
message?: string,
|
||||
onProgress?: (progress: string) => void,
|
||||
closeAfter = false
|
||||
): Promise<SaveAndVersionResult> {
|
||||
// FR-17 — หยุด auto-save ตลอด flow กัน save แทรกกลาง stamp/capture แล้วค่อนคืนตอนจบ
|
||||
await autoSaveService.suspend();
|
||||
try {
|
||||
const clientVersionId = generateGuid();
|
||||
const timestamp = new Date().toISOString();
|
||||
const predictedNumber = latestVersionNumber + 1;
|
||||
const user = authService.getUsername() ?? "unknown";
|
||||
|
||||
// 1) stamp properties (SavedByAddIn + DocumentTrackingId ถูก stamp ไว้แล้วจากรอบแรก — เขียนทับไม่เสียหาย)
|
||||
onProgress?.("กำลังบันทึก metadata ของ version ใหม่…");
|
||||
await stampTrackedProperties({
|
||||
lastVersionId: clientVersionId,
|
||||
lastVersionTimestamp: timestamp,
|
||||
});
|
||||
|
||||
// 2) XML part: เพิ่ม entry ของ version ใหม่ไว้ต้นสุด=ใหม่→เก่า ตามรูปใน spec (เดาเลขก่อน upload) — fail ได้เงียบๆ
|
||||
onProgress?.("กำลังอัปเดต version history ในไฟล์…");
|
||||
const entries = await readVersionHistoryFromXml();
|
||||
entries.unshift({ id: clientVersionId, number: predictedNumber, timestamp, user });
|
||||
await writeVersionHistoryToXml(entries);
|
||||
|
||||
// 3) native save → capture ไฟล์ + fields ปัจจุบัน (FR-11)
|
||||
onProgress?.("กำลังบันทึกไฟล์…");
|
||||
await saveDocumentVerified();
|
||||
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
|
||||
const fields = await readContentControlFields();
|
||||
|
||||
// 4) upload
|
||||
onProgress?.("กำลังอัปโหลด version ใหม่…");
|
||||
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress);
|
||||
|
||||
// 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort)
|
||||
if (result.versionNumber !== predictedNumber) {
|
||||
const corrected = await readVersionHistoryFromXml();
|
||||
const entry = corrected.find((item: XmlVersionEntry) => item.id === clientVersionId);
|
||||
if (entry) {
|
||||
entry.number = result.versionNumber;
|
||||
corrected.sort((a, b) => b.number - a.number); // คงลำดับใหม่→เก่าหลังแก้เลข
|
||||
await writeVersionHistoryToXml(corrected);
|
||||
}
|
||||
}
|
||||
|
||||
// FR-3 — สร้าง version สำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version"
|
||||
autoSaveService.clearUncommittedSinceVersion();
|
||||
|
||||
// 6) FR-18 — ปิดเอกสารเฉพาะเมื่อ upload สำเร็จแล้วเท่านั้น (ต้องอยู่หลังขั้น 5 เพราะหลัง close
|
||||
// เอกสารหมดสิทธิ์เรียก Word.run ต่อ)
|
||||
if (closeAfter) {
|
||||
try {
|
||||
await closeDocument(DocumentCloseMode.Save);
|
||||
} catch (error) {
|
||||
throw new VersionSavedButCloseFailedError(result, error);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
autoSaveService.resume();
|
||||
}
|
||||
}
|
||||
154
wordaddin/services/versioningService.ts
Normal file
154
wordaddin/services/versioningService.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* MOCK — version API ฝั่ง client (รอ backend จริงตาม contract ใน docs/addin-ui-flow.md §7)
|
||||
* ต้นฉบับ: D:\word-addin-poc\addin\src\services\versioningService.ts (multipart POST ผ่าน apiClient)
|
||||
*
|
||||
* - คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId ไว้เป๊ะ —
|
||||
* วันปลด mock: ก้อบ body ของต้นฉบับกลับมาได้เลย
|
||||
* - ต้นฉบับมีอีก 2 ตัวที่ไม่มี caller ใน UI จึงไม่พอร์ต: buildDownloadUrl, downloadVersion
|
||||
* - **metadata ของ version ถูก persist ลง localStorage** (record เล็ก ๆ ต่อ documentId) —
|
||||
* ไม่งั้นปิดเอกสาร (taskpane ปิดตาม) แล้วเปิดใหม่ history ว่างเปล่า ทั้งที่ metadata
|
||||
* การ track อยู่ในไฟล์จริง ทำให้ทดสอบวง FR-18 ปิด→เปิดใหม่ไม่ได้; blob ของไฟล์จริง
|
||||
* ยังเก็บเฉพาะ session (mock download คืน about:blank อยู่แล้ว) — localStorage เข้า
|
||||
* ไม่ได้ (webview บางตัว block) = ใช้ memory อย่างเดียวแบบเดิม ไม่ throw
|
||||
*/
|
||||
|
||||
import * as authService from "./authService";
|
||||
import { generateGuid } from "./metadataService";
|
||||
import { UploadVersionResult, VersionRecord } from "../models/versionRecord";
|
||||
|
||||
interface StoredVersion {
|
||||
record: VersionRecord;
|
||||
docxBlob: Blob | null;
|
||||
pdfBlob: Blob | null;
|
||||
}
|
||||
|
||||
/** key = documentId, เก็บเรียงเก่า → ใหม่ */
|
||||
const store = new Map<string, StoredVersion[]>();
|
||||
|
||||
const STORAGE_KEY = "wordaddin-mock-versions";
|
||||
|
||||
/** โหลด record ที่ persist ไว้ (เฉพาะ metadata — blob ไม่ข้าม session) — พังใด ๆ = เริ่มว่าง */
|
||||
function loadPersisted(): Record<string, VersionRecord[]> {
|
||||
try {
|
||||
if (typeof window === "undefined") return {}; // SSR ของ client component — ไม่มี localStorage
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
const parsed = raw ? (JSON.parse(raw) as Record<string, VersionRecord[]>) : {};
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const persisted = loadPersisted();
|
||||
for (const [documentId, records] of Object.entries(persisted)) {
|
||||
if (Array.isArray(records)) {
|
||||
store.set(documentId, records.map((record) => ({ record, docxBlob: null, pdfBlob: null })));
|
||||
}
|
||||
}
|
||||
|
||||
/** บันทึกเฉพาะ metadata กลับลง localStorage — พัง = เงียบ (ยังมี memory ของ session นี้อยู่) */
|
||||
function persist(): void {
|
||||
try {
|
||||
const snapshot: Record<string, VersionRecord[]> = {};
|
||||
store.forEach((versions, documentId) => {
|
||||
snapshot[documentId] = versions.map((item) => item.record);
|
||||
});
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
// ignore — quota/private mode
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** SHA-256 จริงของไฟล์ที่ได้จาก Word — พังก็ไม่ throw (ใช้ค่าหยง ๆ ให้ UI ยังไปต่อได้) */
|
||||
async function mockContentHash(file: Blob): Promise<string> {
|
||||
try {
|
||||
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
} catch {
|
||||
return "mock-hash";
|
||||
}
|
||||
}
|
||||
|
||||
/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (mock: เก็บลง memory)
|
||||
* + FR-13: message คือ commit message ทางเลือก
|
||||
* + FR-16: templateId เฉพาะไฟล์ร่าง (mock ไม่ใช้ — คงไว้ตาม signature ต้นฉบับ) */
|
||||
export async function uploadVersion(
|
||||
documentId: string,
|
||||
file: Blob,
|
||||
clientVersionId: string,
|
||||
fields?: Record<string, string>,
|
||||
fileName?: string,
|
||||
message?: string,
|
||||
pdf?: Blob,
|
||||
templateId?: string
|
||||
): Promise<UploadVersionResult> {
|
||||
void clientVersionId; // mock ไม่ใช้ — id ของ mock สร้างเอง
|
||||
void fileName;
|
||||
void templateId;
|
||||
await delay(500); // ให้ข้อความ progress ของ flow ขึ้นจริง
|
||||
|
||||
const versions = store.get(documentId) ?? [];
|
||||
const versionNumber = versions.reduce((max, item) => Math.max(max, item.record.versionNumber), 0) + 1;
|
||||
const hasPdf = !!pdf && pdf.size > 0;
|
||||
const trimmedMessage = message?.trim();
|
||||
|
||||
const record: VersionRecord = {
|
||||
versionId: generateGuid(),
|
||||
versionNumber,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdByUserId: authService.getUsername() ?? "unknown",
|
||||
contentHash: await mockContentHash(file),
|
||||
message: trimmedMessage || null,
|
||||
hasPdf,
|
||||
fields: fields && Object.keys(fields).length > 0 ? fields : null,
|
||||
};
|
||||
versions.push({ record, docxBlob: file, pdfBlob: pdf ?? null });
|
||||
store.set(documentId, versions);
|
||||
persist();
|
||||
return { hasPdf, versionId: record.versionId, versionNumber };
|
||||
}
|
||||
|
||||
/** FR-6 — version history เรียงใหม่ → เก่า (คืนสำเนากัน caller mutate store ตรง ๆ) */
|
||||
export async function getVersions(documentId: string): Promise<VersionRecord[]> {
|
||||
await delay(200);
|
||||
const versions = store.get(documentId) ?? [];
|
||||
return versions
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((item) => ({ ...item.record }));
|
||||
}
|
||||
|
||||
export interface VersionDownloadLink {
|
||||
url: string;
|
||||
/** ISO-8601 เวลาที่ URL หมดอายุ */
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FR-7 — ขอ presigned URL อายุสั้นของ version หนึ่ง เพื่อเปิดในเบราว์เซอร์ของผู้ใช้
|
||||
* (webview ของ Word ทิ้ง blob download เงียบๆ — ให้เบราว์เซอร์จริงจัดการแทน)
|
||||
* mock: ไม่มี URL จริงให้คืน — blob:/data: ใช้ผ่าน openBrowserWindow ไม่ได้ (blob ผูกกับ
|
||||
* context ของ webview, data: โดนเบราว์เซอร์ block) ส่วน about:blank ที่คืนไปนี้ host จะ
|
||||
* ปฏิเสธเงียบๆ เพราะ openBrowserWindow เปิดเฉพาะ http/https และเป็น void API ไม่รายงานกลับ
|
||||
* — กดปุ่มดาวน์โหลด/PDF ตอน mock จึงไม่มีหน้าต่างเบราว์เซอร์เปิด (UI ยังเดินครบ flow)
|
||||
*/
|
||||
export async function getDownloadLink(
|
||||
documentId: string,
|
||||
versionId: string,
|
||||
format?: "docx" | "pdf"
|
||||
): Promise<VersionDownloadLink> {
|
||||
void documentId;
|
||||
void versionId;
|
||||
void format;
|
||||
await delay(150);
|
||||
return { url: "about:blank", expiresAt: new Date(Date.now() + 10 * 60_000).toISOString() };
|
||||
}
|
||||
|
||||
/** สร้าง clientVersionId ใหม่สำหรับ upload ครั้งถัดไป */
|
||||
export function newClientVersionId(): string {
|
||||
return generateGuid();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue