/* 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 | undefined; private periodicTimer: ReturnType | 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 { 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 { 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 { 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 { 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 { return this.saving ? new Promise((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();