eCourt-note/wordaddin/services/autoSaveService.ts
Your Name c401f4deb7 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>
2026-10-16 12:07:25 +07:00

208 lines
7.5 KiB
TypeScript

/* 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();