/* 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() * → ดึงไฟล์ → POST version → คืนผลให้ UI แสดง/refresh history * (FR-11 fields เดิมอ่านฝั่ง client ตอน capture — 2026-10-30 ยกให้ server แกะจาก * ไฟล์ .docx ด้วยตัวเดียวกับ /api/template/extract แทน จึงไม่มีขั้นอ่าน fields แล้ว) * * หมายเหตุค่า id ใน metadata: `LastVersionId` และ `` ใน 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, readVersionHistoryFromXml, stampTrackedProperties, writeVersionHistoryToXml, XmlVersionEntry, } from "./metadataService"; import { uploadVersion } from "./versioningService"; /** 2026-11-05 — tracking id ใช้ **fid จาก ticket** (ตรงกับ docId ของทุก operation ฝั่ง server — * uploadVersion real mode ไม่ส่ง documentId ไป server อยู่แล้ว identity มาจาก Bearer) แทน GUID * สุ่มใหม่ · **ย้อนกลับเป็นแบบเดิม**: เปลี่ยนค่าเป็น "guid" (generateGuid ยังอยู่ — ไฟล์เก่า * ที่ stamp GUID ไว้แล้วยัง track ได้เพราะ detect อ่านค่าตาม property ไม่สนรูปแบบค่า) */ const TRACKING_ID_SOURCE: "fid" | "guid" = "fid"; /** เลือกค่า tracking id ตามโหมด — pure แยกไว้ให้ unit test ได้ (ไม่มี fid = fallback GUID เดิม) */ export function resolveTrackingId(currentFid: string | null): string { return TRACKING_ID_SOURCE === "fid" && currentFid !== null ? currentFid : generateGuid(); } 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, message?: string, onProgress?: (progress: string) => void, templateId?: string ): Promise { 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, undefined, 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 { // FR-17 — หยุด auto-save ตลอด flow กัน save แทรกกลาง stamp/capture แล้วค่อนคืนตอนจบ await autoSaveService.suspend(); try { const documentId = resolveTrackingId(authService.getCurrentFid()); 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 ของ version แกะจากไฟล์ฝั่ง server // ด้วยตัวเดียวกับ /api/template/extract — contract 2026-10-30 ไม่ต้องอ่านเองแล้ว) onProgress?.("กำลังบันทึกไฟล์…"); await saveDocumentVerified(); // 4) upload version แรก (แนบ templateId เมื่อเป็นไฟล์ร่าง — FR-16) onProgress?.("กำลังอัปโหลด version 1…"); const result = await uploadCurrentFile(documentId, clientVersionId, message, onProgress, templateId); // 5) final save (2026-11-05 bug fix) — การันตีว่าทุกอย่างที่ stamp ใน flow นี้ persist // ถึงดิสก์จริงก่อนจบ (เดิม save ครั้งเดียวช่วงขั้น 3 ก่อน upload — เคยเจอไฟล์ถูก stamp // แล้วได้ version แต่ดิสก์ไม่ได้รับค่า ปิดไฟล์กลับมาเปิดใหม่กลายเป็น untracked) onProgress?.("กำลังบันทึกไฟล์…"); await saveDocumentVerified(); // 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 { // 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 แกะฝั่ง server จากไฟล์ที่อัปโหลด — 2026-10-30) onProgress?.("กำลังบันทึกไฟล์…"); await saveDocumentVerified(); // 4) upload onProgress?.("กำลังอัปโหลด version ใหม่…"); const result = await uploadCurrentFile(documentId, clientVersionId, 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); } } // 6) final save (2026-11-05 bug fix) — ขั้น 5 เขียน XML part หลัง upload โดยไม่มี save ตาม // = เอกสาร dirty ตอน flow จบ ผู้ใช้ปิด Word โดยไม่ save เพิ่ม = ค่าที่ stamp ทั้ง flow // (รวม tracking properties ของขั้น 1) ไม่ถึงดิสก์ → เปิดใหม่กลายเป็น untracked — // ต้อง save ปิดท้ายหลังทุก mutation ก่อนคืนผล (และก่อน close เพราะหลัง close เรียก // Word.run ต่อไม่ได้) onProgress?.("กำลังบันทึกไฟล์…"); await saveDocumentVerified(); // FR-3 — สร้าง version สำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version" autoSaveService.clearUncommittedSinceVersion(); // 7) FR-18 — ปิดเอกสารเฉพาะเมื่อ upload สำเร็จแล้วเท่านั้น (ต้องอยู่หลังขั้น final save // เพราะหลัง close เอกสารหมดสิทธิ์เรียก Word.run ต่อ) if (closeAfter) { try { await closeDocument(DocumentCloseMode.Save); } catch (error) { throw new VersionSavedButCloseFailedError(result, error); } } return result; } finally { autoSaveService.resume(); } }