eCourt-note/wordaddin/services/autoSaveService.ts

235 lines
9.8 KiB
TypeScript
Raw Normal View History

2026-10-16 12:07:25 +07:00
/* 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 จริงคือ service ที่แก้เอกสาร
* (เครื่องมือ template) หลัง operation สำเร็จ: ตั้ง flag FR-3 ทันที + debounce save
* - periodic ทุก 30 วิ: อ่าน `document.saved` ก่อน — dirty → save, สะอาด → ข้าม
* ตั้ง flag FR-3 เฉพาะ **clean→dirty** (เคยเห็นสะอาดก่อน): ไฟล์ที่เปิดมา dirty ตั้งแต่แรก —
* quirk ของ Word ที่รายงาน saved=false ค้างหลัง stamp properties/custom XML part
* หรือแก้ค้างจาก session ก่อน — ไม่ถูกนับเป็น "ผู้ใช้แก้" กัน banner FR-3 เด้งทั้งที่ผู้ใช้
* ไม่ได้ทำอะไรเลย (คำขอผู้ใช้ 2026-11-03; ทุกกรณียัง save ให้เหมือนเดิม — ปลอดภัยไว้ก่อน)
2026-10-16 12:07:25 +07:00
* - ช่วง 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;
/** เคยเห็น document สะอาด (saved=true) อย่างน้อยหนึ่งจุดตั้งแต่ start — clean→dirty ถัดไปคือ "ผู้ใช้แก้จริง" */
private seenClean = false;
2026-10-16 12:07:25 +07:00
/** 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();
void this.seedBaseline();
2026-10-16 12:07:25 +07:00
}
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 ที่แก้เอกสาร (เครื่องมือ template) เรียกหลัง operation สำเร็จ:
* การแก้ไขเกิดจริงเสมอจึงตั้ง flag FR-3 ทันที ไม่รอ clean→dirty ของ periodic tick */
2026-10-16 12:07:25 +07:00
notifyDocumentChanged(): void {
if (!autoSaveConfig.enabled) {
return;
}
this.uncommitted = true;
this.notifyState();
2026-10-16 12:07:25 +07:00
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 {
const dirty = await isDocumentDirty();
if (!dirty) {
// จุดที่เห็นสะอาด — clean→dirty ของ tick ถัดไปคือการแก้ไขจริงของผู้ใช้
this.seenClean = true;
2026-10-16 12:07:25 +07:00
return;
}
// FR-3 — ตั้ง flag เฉพาะ clean→dirty: dirty ตั้งแต่เปิดไฟล์/ติดจาก quirk หลัง stamp
// ไม่ใช่ฝีมือผู้ใช้ จึงไม่เด้ง banner ให้งง (ยัง save ตามปกติ)
if (this.seenClean) {
this.uncommitted = true;
this.notifyState();
}
2026-10-16 12:07:25 +07:00
await this.save();
} catch (error) {
this.reportError(error);
}
}
/** จุดตั้งต้นตอน start — ไฟล์ที่เปิดมา dirty ตั้งแต่แรกไม่นับเป็น "ผู้ใช้แก้" (ดูหัวไฟล์)
* อ่านไม่ได้ = เงียบไว้ (ค่า false เป็นค่าอนุรักษ์นิยม: ไม่ติด flag จนกว่าจะเห็น clean จริง) */
private async seedBaseline(): Promise<void> {
try {
this.seenClean = !(await isDocumentDirty());
} catch {
this.seenClean = false;
}
}
2026-10-16 12:07:25 +07:00
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();