- Implement tests for various scenarios including user edits, initial dirty state, and document changes. - Ensure proper flagging of uncommitted changes and silent saves for documents opened in a dirty state. - Test debounce behavior for document change notifications and version clearing functionality. - Validate suspension and resumption of auto-saving during versioning processes.
234 lines
9.8 KiB
TypeScript
234 lines
9.8 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 จริงคือ 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 ให้เหมือนเดิม — ปลอดภัยไว้ก่อน)
|
|
* - ช่วง 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;
|
|
|
|
/** 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();
|
|
}
|
|
|
|
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 */
|
|
notifyDocumentChanged(): void {
|
|
if (!autoSaveConfig.enabled) {
|
|
return;
|
|
}
|
|
this.uncommitted = true;
|
|
this.notifyState();
|
|
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;
|
|
return;
|
|
}
|
|
// FR-3 — ตั้ง flag เฉพาะ clean→dirty: dirty ตั้งแต่เปิดไฟล์/ติดจาก quirk หลัง stamp
|
|
// ไม่ใช่ฝีมือผู้ใช้ จึงไม่เด้ง banner ให้งง (ยัง save ตามปกติ)
|
|
if (this.seenClean) {
|
|
this.uncommitted = true;
|
|
this.notifyState();
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
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();
|