209 lines
7.5 KiB
TypeScript
209 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();
|