add-in: auto-open taskpane phase 1 + final save after versioning + tracking id = fid
- manifest: TaskpaneId -> Office.AutoShowTaskpaneWithDocument (auto-open marker), bump 1.0.0.4 - metadataService.enableAutoOpenTaskpane: tag doc setting (idempotent, best-effort) called from detect() after sign-in - versionFlow: final saveDocumentVerified at end of both versioning flows (bug: stamped docx never persisted -> reopen showed untracked) - tracking id now uses ticket fid (TRACKING_ID_SOURCE="fid", rollback by switching to "guid"), resolveTrackingId + authService.getCurrentFid - WordAddinGate: restore history.pushState/replaceState that office.js nullifies in taskpane (fixes Next App Router crash) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
02894f2705
commit
9fecaa7bca
8 changed files with 256 additions and 6 deletions
|
|
@ -212,6 +212,10 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
|
||||||
}
|
}
|
||||||
setUsername(authService.getUsername() ?? null);
|
setUsername(authService.getUsername() ?? null);
|
||||||
|
|
||||||
|
// auto-open (2026-11-05) — sign-in ผ่าน = ไฟล์ e-Court: tag ให้ taskpane เด้งเองครั้งถัดไป
|
||||||
|
// ที่เปิดไฟล์นี้ · fire-and-forget (service กลืน error ทั้งหมด — ไฟล์ทั่วไปไม่มาทางนี้อยู่แล้ว)
|
||||||
|
void metadataService.enableAutoOpenTaskpane();
|
||||||
|
|
||||||
const tracked = properties.savedByAddIn === metadataService.ADDIN_SIGNATURE_GUID;
|
const tracked = properties.savedByAddIn === metadataService.ADDIN_SIGNATURE_GUID;
|
||||||
const id = tracked ? properties.documentTrackingId : undefined;
|
const id = tracked ? properties.documentTrackingId : undefined;
|
||||||
if (!id) {
|
if (!id) {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,33 @@ const ONREADY_FALLBACK_MS = 5_000;
|
||||||
/** เพดานรวมหลัง inject script — ครบแล้วยังไม่มี Office = ไปหน้า error (มีปุ่มลองใหม่) */
|
/** เพดานรวมหลัง inject script — ครบแล้วยังไม่มี Office = ไปหน้า error (มีปุ่มลองใหม่) */
|
||||||
const ONREADY_HARD_CAP_MS = 60_000;
|
const ONREADY_HARD_CAP_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* office.js **ลบ `history.pushState`/`replaceState` ทิ้ง** ใน taskpane (set เป็น null —
|
||||||
|
* กัน add-in พา host ไปที่อื่น: office-js#429) แต่ App Router ของ Next ต้องใช้ History API
|
||||||
|
* ตอน mount ไม่งั้นพัง "window.history.replaceState is not a function" — เก็บตัวจริงไว้
|
||||||
|
* ก่อนฉีด script แล้วคืนคืนหลัง office.js โหลด/พร้อม (workaround มาตรฐานของ SPA ใน
|
||||||
|
* add-in — taskpane เป็น iframe ของตัวเอง Next ใช้ history เฉพาะ SPA state ภายใน)
|
||||||
|
*/
|
||||||
|
const nativeHistory: { pushState?: History["pushState"]; replaceState?: History["replaceState"] } =
|
||||||
|
typeof window === "undefined"
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
pushState: typeof window.history.pushState === "function" ? window.history.pushState.bind(window.history) : undefined,
|
||||||
|
replaceState:
|
||||||
|
typeof window.history.replaceState === "function" ? window.history.replaceState.bind(window.history) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** คืน History API ที่ office.js ลบทิ้ง — idempotent (มีอยู่แล้ว = ไม่แตะ) */
|
||||||
|
function restoreHistoryApi(): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (typeof window.history.pushState !== "function" && nativeHistory.pushState) {
|
||||||
|
window.history.pushState = nativeHistory.pushState;
|
||||||
|
}
|
||||||
|
if (typeof window.history.replaceState !== "function" && nativeHistory.replaceState) {
|
||||||
|
window.history.replaceState = nativeHistory.replaceState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* โหลด office.js ครั้งเดียวต่อ page เป็น singleton promise ระดับ module —
|
* โหลด office.js ครั้งเดียวต่อ page เป็น singleton promise ระดับ module —
|
||||||
* ห้ามฉีด <script> ซ้ำ: React StrictMode รัน effect สองรอบใน dev และ guard
|
* ห้ามฉีด <script> ซ้ำ: React StrictMode รัน effect สองรอบใน dev และ guard
|
||||||
|
|
@ -45,7 +72,11 @@ function ensureOfficeJs(): Promise<boolean> {
|
||||||
if (!officeReadyPromise) {
|
if (!officeReadyPromise) {
|
||||||
officeReadyPromise = new Promise<boolean>((resolve) => {
|
officeReadyPromise = new Promise<boolean>((resolve) => {
|
||||||
if (typeof Office !== "undefined") {
|
if (typeof Office !== "undefined") {
|
||||||
void Office.onReady(() => resolve(true));
|
restoreHistoryApi(); // office.js evaluate ไปแล้ว — คืนก่อนให้ router ใช้ต่อได้
|
||||||
|
void Office.onReady(() => {
|
||||||
|
restoreHistoryApi(); // host handshake อาจลบซ้ำตอน initialize
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
|
|
@ -56,6 +87,7 @@ function ensureOfficeJs(): Promise<boolean> {
|
||||||
// Office โผล่แล้ว (script evaluate แล้ว) = proceed ได้เลย ไม่รอ onReady —
|
// Office โผล่แล้ว (script evaluate แล้ว) = proceed ได้เลย ไม่รอ onReady —
|
||||||
// กัน webview ที่โหลดสำเร็จแต่ callback เงียบ (เจตนาเดิมของ fallback)
|
// กัน webview ที่โหลดสำเร็จแต่ callback เงียบ (เจตนาเดิมของ fallback)
|
||||||
if (typeof Office !== "undefined") {
|
if (typeof Office !== "undefined") {
|
||||||
|
restoreHistoryApi(); // office.js evaluate แล้วอาจลบ history ทิ้ง — คืนก่อน proceed
|
||||||
resolve(true);
|
resolve(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -74,8 +106,10 @@ function ensureOfficeJs(): Promise<boolean> {
|
||||||
const onReady = () => {
|
const onReady = () => {
|
||||||
// เคลียร์ timer เฉพาะเมื่อ callback ของ Office.onReady ยิงจริง — ถ้าเคลียร์ตั้งแต่ onload
|
// เคลียร์ timer เฉพาะเมื่อ callback ของ Office.onReady ยิงจริง — ถ้าเคลียร์ตั้งแต่ onload
|
||||||
// แล้ว onReady เงียบ fallback จะตายไปก่อนทำหน้าที่ (ค้าง loading ตลอดไป)
|
// แล้ว onReady เงียบ fallback จะตายไปก่อนทำหน้าที่ (ค้าง loading ตลอดไป)
|
||||||
|
restoreHistoryApi(); // script evaluate แล้ว — office.js ลบ history ตรงนี้ คืนก่อน router ใช้
|
||||||
void Office.onReady(() => {
|
void Office.onReady(() => {
|
||||||
clearTimeout(fallbackTimer);
|
clearTimeout(fallbackTimer);
|
||||||
|
restoreHistoryApi(); // handshake ของ host อาจลบซ้ำ — คืนอีกรอบก่อน mount TaskpaneApp
|
||||||
resolve(true);
|
resolve(true);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
-->
|
-->
|
||||||
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:ov="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="TaskPaneApp">
|
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:ov="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="TaskPaneApp">
|
||||||
<Id>4a1e184a-ab31-4fa4-b3c7-9a2283051c3f</Id>
|
<Id>4a1e184a-ab31-4fa4-b3c7-9a2283051c3f</Id>
|
||||||
<Version>1.0.0.3</Version>
|
<Version>1.0.0.4</Version>
|
||||||
<ProviderName>e-Court Documents Versioning</ProviderName>
|
<ProviderName>e-Court Documents Versioning</ProviderName>
|
||||||
<DefaultLocale>en-US</DefaultLocale>
|
<DefaultLocale>en-US</DefaultLocale>
|
||||||
<DisplayName DefaultValue="e-Court Documents Versioning"/>
|
<DisplayName DefaultValue="e-Court Documents Versioning"/>
|
||||||
|
|
@ -67,8 +67,14 @@
|
||||||
<bt:Image size="32" resid="Icon.32x32"/>
|
<bt:Image size="32" resid="Icon.32x32"/>
|
||||||
<bt:Image size="80" resid="Icon.80x80"/>
|
<bt:Image size="80" resid="Icon.80x80"/>
|
||||||
</Icon>
|
</Icon>
|
||||||
|
<!-- TaskpaneId ค่านี้เป็น **ค่าคงที่ตามข้อบังคับของ Office** สำหรับฟีเจอร์ auto-open
|
||||||
|
(learn.microsoft.com/.../automatically-open-a-task-pane-with-a-document) —
|
||||||
|
ห้ามเปลี่ยนกลับเป็นชื่ออื่น: ต้องตรงกับ document setting ชื่อเดียวกันที่
|
||||||
|
metadataService.enableAutoOpenTaskpane เขียน และ <we:property> ที่ lib/lib-tmpl.ts
|
||||||
|
stamp ลง word/webextensions/ ฝั่ง server — เปลี่ยนค่า = auto-open ตายทั้งสองฝั่ง
|
||||||
|
(โบนัส: marker นี้ทำให้ pane เด้งอัตโนมัติตอนติดตั้ง add-in ครั้งแรกด้วย) -->
|
||||||
<Action xsi:type="ShowTaskpane">
|
<Action xsi:type="ShowTaskpane">
|
||||||
<TaskpaneId>ButtonId1</TaskpaneId>
|
<TaskpaneId>Office.AutoShowTaskpaneWithDocument</TaskpaneId>
|
||||||
<SourceLocation resid="Taskpane.Url"/>
|
<SourceLocation resid="Taskpane.Url"/>
|
||||||
</Action>
|
</Action>
|
||||||
</Control>
|
</Control>
|
||||||
|
|
|
||||||
|
|
@ -93,3 +93,103 @@ describe("readContentControlFields", () => {
|
||||||
expect(await readContentControlFields()).toEqual({});
|
expect(await readContentControlFields()).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── enableAutoOpenTaskpane (auto-open 2026-11-05) ──────────────────────────
|
||||||
|
// function มี module-level memo (กัน double-call ของ detect) — แยกเคสด้วย
|
||||||
|
// vi.resetModules + dynamic import ให้ทุกเคสได้ module ใหม่ ไม่ให้ memo รั่ยข้ามเคส
|
||||||
|
interface StubbedSettings {
|
||||||
|
get: ReturnType<typeof vi.fn>;
|
||||||
|
set: ReturnType<typeof vi.fn>;
|
||||||
|
saveAsync: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** stub Office.context.document.settings + Word.run ของ saveDocumentVerified */
|
||||||
|
function stubAutoOpen(options?: { existing?: boolean; saveOk?: boolean; nativeSaveThrows?: boolean }): {
|
||||||
|
settings: StubbedSettings;
|
||||||
|
saveFn: ReturnType<typeof vi.fn>;
|
||||||
|
} {
|
||||||
|
const settings = {
|
||||||
|
get: vi.fn(() => (options?.existing === true ? true : undefined)),
|
||||||
|
set: vi.fn(),
|
||||||
|
saveAsync: vi.fn((callback: (result: { status: string }) => void) =>
|
||||||
|
callback({ status: options?.saveOk === false ? "failed" : "succeeded" }),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const saveFn = vi.fn(() => {
|
||||||
|
if (options?.nativeSaveThrows) throw new Error("read-only");
|
||||||
|
});
|
||||||
|
vi.stubGlobal("Office", {
|
||||||
|
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
|
||||||
|
context: { document: { settings } },
|
||||||
|
});
|
||||||
|
vi.stubGlobal("Word", {
|
||||||
|
run: async (batch: (context: unknown) => Promise<unknown>) =>
|
||||||
|
batch({
|
||||||
|
document: { save: saveFn, load: vi.fn(), saved: true },
|
||||||
|
sync: async () => undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return { settings, saveFn };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importFreshMetadata() {
|
||||||
|
vi.resetModules();
|
||||||
|
return await import("@/wordaddin/services/metadataService");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("enableAutoOpenTaskpane", () => {
|
||||||
|
it("ยังไม่มี setting → set(true) + saveAsync + native save ครบ คืน true", async () => {
|
||||||
|
const { settings, saveFn } = stubAutoOpen();
|
||||||
|
const metadata = await importFreshMetadata();
|
||||||
|
|
||||||
|
const ok = await metadata.enableAutoOpenTaskpane();
|
||||||
|
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(metadata.AUTO_OPEN_SETTING).toBe("Office.AutoShowTaskpaneWithDocument");
|
||||||
|
expect(settings.set).toHaveBeenCalledWith("Office.AutoShowTaskpaneWithDocument", true);
|
||||||
|
expect(settings.saveAsync).toHaveBeenCalledTimes(1);
|
||||||
|
expect(saveFn).toHaveBeenCalled(); // saveDocumentVerified ตามหลัง saveAsync
|
||||||
|
});
|
||||||
|
|
||||||
|
it("มี setting = true อยู่แล้ว → no-op idempotent ไม่ set ไม่ saveAsync", async () => {
|
||||||
|
const { settings, saveFn } = stubAutoOpen({ existing: true });
|
||||||
|
const metadata = await importFreshMetadata();
|
||||||
|
|
||||||
|
expect(await metadata.enableAutoOpenTaskpane()).toBe(true);
|
||||||
|
expect(settings.set).not.toHaveBeenCalled();
|
||||||
|
expect(settings.saveAsync).not.toHaveBeenCalled();
|
||||||
|
expect(saveFn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("นอก host Word (ไม่มี Office global) → คืน false ไม่ throw", async () => {
|
||||||
|
const metadata = await importFreshMetadata(); // ไม่ stub อะไรเลย
|
||||||
|
expect(await metadata.enableAutoOpenTaskpane()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("settings.saveAsync ล้ม → คืน false ไม่ throw", async () => {
|
||||||
|
stubAutoOpen({ saveOk: false });
|
||||||
|
const metadata = await importFreshMetadata();
|
||||||
|
expect(await metadata.enableAutoOpenTaskpane()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("native save ล้ม (ไฟล์ read-only) → ยังคืน true (setting เข้า memory doc แล้ว) + warn ไม่ throw", async () => {
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
|
stubAutoOpen({ nativeSaveThrows: true });
|
||||||
|
const metadata = await importFreshMetadata();
|
||||||
|
|
||||||
|
expect(await metadata.enableAutoOpenTaskpane()).toBe(true);
|
||||||
|
expect(warn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("memo: ล้มครั้งแรกแล้วล้าง — call ถัดไปยิงใหม่ได้ (สำเร็จในครั้งที่สอง)", async () => {
|
||||||
|
const { settings } = stubAutoOpen({ saveOk: false });
|
||||||
|
const metadata = await importFreshMetadata();
|
||||||
|
|
||||||
|
expect(await metadata.enableAutoOpenTaskpane()).toBe(false);
|
||||||
|
settings.saveAsync.mockImplementation(
|
||||||
|
(callback: (result: { status: string }) => void) => callback({ status: "succeeded" }),
|
||||||
|
);
|
||||||
|
expect(await metadata.enableAutoOpenTaskpane()).toBe(true);
|
||||||
|
expect(settings.saveAsync).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
18
tests/wordaddin/versionFlow.test.ts
Normal file
18
tests/wordaddin/versionFlow.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
// resolveTrackingId (งาน B 2026-11-05) — tracking id ของ Start Versioning ใช้ fid จาก
|
||||||
|
// ticket แทน GUID สุ่มใหม่ (TRACKING_ID_SOURCE = "fid") · ไม่มี fid = fallback GUID เดิม
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { resolveTrackingId } from "@/wordaddin/services/versionFlow";
|
||||||
|
|
||||||
|
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||||
|
|
||||||
|
describe("resolveTrackingId", () => {
|
||||||
|
it("มี fid (sign-in ผ่านแล้ว) → ใช้ fid ตรง ๆ ตรงกับ docId ฝั่ง server", () => {
|
||||||
|
expect(resolveTrackingId("T12_FD155_3s-long_ZZZ")).toBe("T12_FD155_3s-long_ZZZ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ไม่มี fid (null) → fallback เป็น GUID รูปแบบเดิม", () => {
|
||||||
|
expect(resolveTrackingId(null)).toMatch(GUID_RE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -189,3 +189,9 @@ export async function getAccessToken(): Promise<string | null> {
|
||||||
export function getUsername(): string | undefined {
|
export function getUsername(): string | undefined {
|
||||||
return username ?? undefined;
|
return username ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** fid ของเอกสารปัจจุบัน (จาก ticket ที่ sign-in ผ่าน) — null = ยังไม่ได้ sign-in หรือโหมด demo
|
||||||
|
* ใช้เป็น tracking id ตอน Start Versioning (versionFlow.resolveTrackingId, 2026-11-05) */
|
||||||
|
export function getCurrentFid(): string | null {
|
||||||
|
return currentFid;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
* (known bug บน Mac บาง build — เขียนไม่ได้ก็ปล่อยผ่าน)
|
* (known bug บน Mac บาง build — เขียนไม่ได้ก็ปล่อยผ่าน)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { saveDocumentVerified } from "./documentService";
|
||||||
|
|
||||||
/** GUID คงที่ของ add-in — ต้องตรงเป๊ะกับ `AddInMetadata.AddInSignatureGuid` ฝั่ง backend (spec ส่วน 8) */
|
/** GUID คงที่ของ add-in — ต้องตรงเป๊ะกับ `AddInMetadata.AddInSignatureGuid` ฝั่ง backend (spec ส่วน 8) */
|
||||||
export const ADDIN_SIGNATURE_GUID = "9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D";
|
export const ADDIN_SIGNATURE_GUID = "9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D";
|
||||||
|
|
||||||
|
|
@ -128,6 +130,60 @@ export async function stampTrackedProperties(input: StampPropertiesInput): Promi
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auto-open taskpane (Office.AutoShowTaskpaneWithDocument) — 2026-11-05
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** ชื่อ setting ที่ทำให้ taskpane เด้งอัตโนมัติเมื่อเปิดเอกสารนี้ครั้งถัดไป — ต้องตรงกับ
|
||||||
|
* `<TaskpaneId>` ใน manifest.template.xml และ `<we:property>` ที่ lib/lib-tmpl.ts stamp
|
||||||
|
* ลง word/webextensions/ ฝั่ง server (learn.microsoft.com/.../automatically-open-a-task-pane-with-a-document) */
|
||||||
|
export const AUTO_OPEN_SETTING = "Office.AutoShowTaskpaneWithDocument";
|
||||||
|
|
||||||
|
/** memo กัน double-call — detect() ถูกเรียกซ้ำ (ปุ่ม "ลองใหม่") ก็แชร์ promise เดิม;
|
||||||
|
* ล้มแล้วล้างทิ้งให้ครั้งถัดไปได้ลองใหม่ */
|
||||||
|
let autoOpenPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tag เอกสารปัจจุบันให้ taskpane เด้งอัตโนมัติครั้งถัดไปที่เปิดไฟล์นี้ — **best-effort
|
||||||
|
* ห้าม throw ทุกกรณี** (auto-open เป็น UX enhancement: ล้ม = ผู้ใช้กดปุ่มเองตามเดิม)
|
||||||
|
* คืน true = setting มีอยู่แล้ว หรือเขียนสำเร็จ · false = ทำไม่สำเร็จ (เงียบ ไม่ใช่ error ของ UI)
|
||||||
|
*/
|
||||||
|
export function enableAutoOpenTaskpane(): Promise<boolean> {
|
||||||
|
if (autoOpenPromise === null) {
|
||||||
|
autoOpenPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const settings = Office.context.document.settings;
|
||||||
|
if (settings.get(AUTO_OPEN_SETTING) === true) return true; // idempotent — ไม่เขียนซ้ำ
|
||||||
|
settings.set(AUTO_OPEN_SETTING, true);
|
||||||
|
const saved = await new Promise<boolean>((resolve) => {
|
||||||
|
settings.saveAsync((result) => resolve(result.status === Office.AsyncResultStatus.Succeeded));
|
||||||
|
});
|
||||||
|
if (!saved) {
|
||||||
|
console.warn("auto-open: settings.saveAsync ไม่สำเร็จ — taskpane จะไม่เด้งครั้งถัดไป");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// saveAsync เขียนเฉพาะ document ในหน่วยความจำ — persist ลงดิสก์จริงต้องตามด้วย native
|
||||||
|
// save (ไฟล์ read-only/Protected View = ยอมแพ้เงียบ ๆ: auto-save 30 วิเป็น net รอง)
|
||||||
|
await saveDocumentVerified();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("auto-open: บันทึกไฟล์ไม่สำเร็จ (read-only?) — setting อาจยังไม่ถึงดิสก์", error);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
// นอก host Word (browser/SSR) หรือ host ไม่รองรับ Settings API — เงียบไป
|
||||||
|
console.warn("auto-open: เขียน setting ไม่สำเร็จ (นอก Word หรือ host ไม่รองรับ)", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
// ล้ม = ล้าง memo ให้ detect ครั้งถัดได้ลองใหม่ (ผ่านปุ่ม "ลองใหม่")
|
||||||
|
void autoOpenPromise.then((ok) => {
|
||||||
|
if (!ok) autoOpenPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return autoOpenPromise;
|
||||||
|
}
|
||||||
|
|
||||||
/** GUID ใหม่สำหรับ tracking id / clientVersionId */
|
/** GUID ใหม่สำหรับ tracking id / clientVersionId */
|
||||||
export function generateGuid(): string {
|
export function generateGuid(): string {
|
||||||
if (typeof crypto.randomUUID === "function") {
|
if (typeof crypto.randomUUID === "function") {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,17 @@ import {
|
||||||
} from "./metadataService";
|
} from "./metadataService";
|
||||||
import { uploadVersion } from "./versioningService";
|
import { uploadVersion } from "./versioningService";
|
||||||
|
|
||||||
|
/** 2026-11-05 — tracking id ใช้ **fid จาก ticket** (ตรงกับ docId ของทุก operation ฝั่ง server —
|
||||||
|
* uploadVersion real mode ไม่ส่ง documentId ไป server อยู่แล้ว identity มาจาก Bearer) แทน GUID
|
||||||
|
* สุ่มใหม่ · **ย้อนกลับเป็นแบบเดิม**: เปลี่ยนค่าเป็น "guid" (generateGuid ยังอยู่ — ไฟล์เก่า
|
||||||
|
* ที่ stamp GUID ไว้แล้วยัง track ได้เพราะ detect อ่านค่าตาม property ไม่สนรูปแบบค่า) */
|
||||||
|
const TRACKING_ID_SOURCE: "fid" | "guid" = "fid";
|
||||||
|
|
||||||
|
/** เลือกค่า tracking id ตามโหมด — pure แยกไว้ให้ unit test ได้ (ไม่มี fid = fallback GUID เดิม) */
|
||||||
|
export function resolveTrackingId(currentFid: string | null): string {
|
||||||
|
return TRACKING_ID_SOURCE === "fid" && currentFid !== null ? currentFid : generateGuid();
|
||||||
|
}
|
||||||
|
|
||||||
export interface StartVersioningResult {
|
export interface StartVersioningResult {
|
||||||
hasPdf: boolean;
|
hasPdf: boolean;
|
||||||
documentId: string;
|
documentId: string;
|
||||||
|
|
@ -121,7 +132,7 @@ export async function startVersioning(
|
||||||
// FR-17 — หยุด auto-save ตลอด flow กัน save แทรกกลาง stamp/capture แล้วค่อนคืนตอนจบ
|
// FR-17 — หยุด auto-save ตลอด flow กัน save แทรกกลาง stamp/capture แล้วค่อนคืนตอนจบ
|
||||||
await autoSaveService.suspend();
|
await autoSaveService.suspend();
|
||||||
try {
|
try {
|
||||||
const documentId = generateGuid();
|
const documentId = resolveTrackingId(authService.getCurrentFid());
|
||||||
const clientVersionId = generateGuid();
|
const clientVersionId = generateGuid();
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
|
@ -147,6 +158,13 @@ export async function startVersioning(
|
||||||
// 4) upload version แรก (แนบ templateId เมื่อเป็นไฟล์ร่าง — FR-16)
|
// 4) upload version แรก (แนบ templateId เมื่อเป็นไฟล์ร่าง — FR-16)
|
||||||
onProgress?.("กำลังอัปโหลด version 1…");
|
onProgress?.("กำลังอัปโหลด version 1…");
|
||||||
const result = await uploadCurrentFile(documentId, clientVersionId, message, onProgress, templateId);
|
const result = await uploadCurrentFile(documentId, clientVersionId, message, onProgress, templateId);
|
||||||
|
|
||||||
|
// 5) final save (2026-11-05 bug fix) — การันตีว่าทุกอย่างที่ stamp ใน flow นี้ persist
|
||||||
|
// ถึงดิสก์จริงก่อนจบ (เดิม save ครั้งเดียวช่วงขั้น 3 ก่อน upload — เคยเจอไฟล์ถูก stamp
|
||||||
|
// แล้วได้ version แต่ดิสก์ไม่ได้รับค่า ปิดไฟล์กลับมาเปิดใหม่กลายเป็น untracked)
|
||||||
|
onProgress?.("กำลังบันทึกไฟล์…");
|
||||||
|
await saveDocumentVerified();
|
||||||
|
|
||||||
// FR-3 — สร้าง version สำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version"
|
// FR-3 — สร้าง version สำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version"
|
||||||
autoSaveService.clearUncommittedSinceVersion();
|
autoSaveService.clearUncommittedSinceVersion();
|
||||||
return { documentId, ...result };
|
return { documentId, ...result };
|
||||||
|
|
@ -206,11 +224,19 @@ export async function saveAndVersion(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6) final save (2026-11-05 bug fix) — ขั้น 5 เขียน XML part หลัง upload โดยไม่มี save ตาม
|
||||||
|
// = เอกสาร dirty ตอน flow จบ ผู้ใช้ปิด Word โดยไม่ save เพิ่ม = ค่าที่ stamp ทั้ง flow
|
||||||
|
// (รวม tracking properties ของขั้น 1) ไม่ถึงดิสก์ → เปิดใหม่กลายเป็น untracked —
|
||||||
|
// ต้อง save ปิดท้ายหลังทุก mutation ก่อนคืนผล (และก่อน close เพราะหลัง close เรียก
|
||||||
|
// Word.run ต่อไม่ได้)
|
||||||
|
onProgress?.("กำลังบันทึกไฟล์…");
|
||||||
|
await saveDocumentVerified();
|
||||||
|
|
||||||
// FR-3 — สร้าง version สำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version"
|
// FR-3 — สร้าง version สำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version"
|
||||||
autoSaveService.clearUncommittedSinceVersion();
|
autoSaveService.clearUncommittedSinceVersion();
|
||||||
|
|
||||||
// 6) FR-18 — ปิดเอกสารเฉพาะเมื่อ upload สำเร็จแล้วเท่านั้น (ต้องอยู่หลังขั้น 5 เพราะหลัง close
|
// 7) FR-18 — ปิดเอกสารเฉพาะเมื่อ upload สำเร็จแล้วเท่านั้น (ต้องอยู่หลังขั้น final save
|
||||||
// เอกสารหมดสิทธิ์เรียก Word.run ต่อ)
|
// เพราะหลัง close เอกสารหมดสิทธิ์เรียก Word.run ต่อ)
|
||||||
if (closeAfter) {
|
if (closeAfter) {
|
||||||
try {
|
try {
|
||||||
await closeDocument(DocumentCloseMode.Save);
|
await closeDocument(DocumentCloseMode.Save);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue