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
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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue