implement phase 5 และ fix bugs
This commit is contained in:
parent
f4b1c3e14b
commit
1bb850c6b8
40 changed files with 1507 additions and 429 deletions
|
|
@ -2,7 +2,7 @@ import * as authService from "./authService";
|
|||
import { apiFetch, apiGetJson, AuthRequiredError, readErrorMessage } from "./apiClient";
|
||||
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
|
||||
|
||||
const API_URL = "http://localhost:5075";
|
||||
const API_URL = "https://localhost:7232";
|
||||
|
||||
beforeEach(() => {
|
||||
authService.signOut();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { createHash } from "crypto";
|
|||
import * as authService from "./authService";
|
||||
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
|
||||
|
||||
const TOKEN_ENDPOINT = "http://localhost:8080/realms/word-versioning/protocol/openid-connect/token";
|
||||
const TOKEN_ENDPOINT =
|
||||
"https://localhost:8443/realms/word-versioning/protocol/openid-connect/token";
|
||||
|
||||
function base64Url(buffer: Buffer): string {
|
||||
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
|
|
@ -110,7 +111,7 @@ describe("buildAuthorizationUrl", () => {
|
|||
authService.buildAuthorizationUrl({ codeChallenge: "the-challenge", state: "the-state" })
|
||||
);
|
||||
|
||||
expect(url.origin).toBe("http://localhost:8080");
|
||||
expect(url.origin).toBe("https://localhost:8443");
|
||||
expect(url.pathname).toBe("/realms/word-versioning/protocol/openid-connect/auth");
|
||||
expect(url.searchParams.get("client_id")).toBe("word-addin-officejs");
|
||||
expect(url.searchParams.get("redirect_uri")).toBe("http://localhost/auth-callback.html");
|
||||
|
|
@ -138,11 +139,11 @@ describe("decodeJwtExpiry", () => {
|
|||
describe("login (Office Dialog + token exchange)", () => {
|
||||
it("เต็ม flow: URL มี PKCE → dialog ส่ง code+state → แลก token ด้วย verifier ที่ pair กัน → isTokenValid", async () => {
|
||||
const office = installOfficeDialogMock();
|
||||
let exchangedBody: URLSearchParams | null = null;
|
||||
const exchanged: { body?: URLSearchParams } = {};
|
||||
const fetchMock = installFetchMock();
|
||||
fetchMock.mockImplementation(async (input: unknown, init?: { body?: string }) => {
|
||||
expect(String(input)).toBe(TOKEN_ENDPOINT);
|
||||
exchangedBody = new URLSearchParams(String(init?.body));
|
||||
exchanged.body = new URLSearchParams(String(init?.body));
|
||||
return mockResponse({
|
||||
status: 200,
|
||||
body: {
|
||||
|
|
@ -169,10 +170,10 @@ describe("login (Office Dialog + token exchange)", () => {
|
|||
expect(office.dialog.close).toHaveBeenCalled();
|
||||
|
||||
// verifier ที่ใช้แลก token ต้อง hash ได้ = challenge ใน URL (pair กันจริง)
|
||||
const verifier = exchangedBody?.get("code_verifier") ?? "";
|
||||
expect(exchangedBody?.get("grant_type")).toBe("authorization_code");
|
||||
expect(exchangedBody?.get("code")).toBe("the-auth-code");
|
||||
expect(exchangedBody?.get("client_id")).toBe("word-addin-officejs");
|
||||
const verifier = exchanged.body?.get("code_verifier") ?? "";
|
||||
expect(exchanged.body?.get("grant_type")).toBe("authorization_code");
|
||||
expect(exchanged.body?.get("code")).toBe("the-auth-code");
|
||||
expect(exchanged.body?.get("client_id")).toBe("word-addin-officejs");
|
||||
expect(base64Url(createHash("sha256").update(verifier).digest())).toBe(challenge);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -216,6 +216,32 @@ export function decodeJwtExpiry(token: string): number | null {
|
|||
}
|
||||
}
|
||||
|
||||
/** อ่านชื่อผู้ใช้จาก access token ปัจจุบัน (`preferred_username` แล้ว fallback `sub`) — ใช้ใส่ใน XML history */
|
||||
export function getUsername(): string | undefined {
|
||||
if (!tokenState.accessToken) {
|
||||
return undefined;
|
||||
}
|
||||
const parts = tokenState.accessToken.split(".");
|
||||
if (parts.length !== 3) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(base64UrlDecode(parts[1])) as {
|
||||
preferred_username?: unknown;
|
||||
sub?: unknown;
|
||||
};
|
||||
if (typeof payload.preferred_username === "string" && payload.preferred_username) {
|
||||
return payload.preferred_username;
|
||||
}
|
||||
if (typeof payload.sub === "string" && payload.sub) {
|
||||
return payload.sub;
|
||||
}
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State transitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
10
addin/src/services/errors.ts
Normal file
10
addin/src/services/errors.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/** ข้อความ error ที่อ่านได้สำหรับแสดงใน task pane — ไม่เคย throw (กติกา: ทุก Office.js/API call ต้อง catch) */
|
||||
export function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string" && error) {
|
||||
return error;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
|
@ -9,10 +9,28 @@ interface FileMockState {
|
|||
/** mock Office.context.document.getFileAsync + File.getSliceAsync/closeAsync */
|
||||
function installOfficeFileMock(
|
||||
slices: Uint8Array[],
|
||||
options: { failSliceIndex?: number; failClose?: boolean; sliceDataForm?: "buffer" | "view" } = {}
|
||||
options: {
|
||||
failSliceIndex?: number;
|
||||
failClose?: boolean;
|
||||
/** รูปร่างของ slice.data ที่ host จริงส่งกลับ — ต่างกันตามแพลตฟอร์ม (Win=ArrayBuffer, Mac=string/array) */
|
||||
sliceDataForm?: "buffer" | "view" | "array" | "base64";
|
||||
} = {}
|
||||
): FileMockState {
|
||||
const state: FileMockState = { getFileCalls: 0, requestedSlices: [], closeCalls: 0 };
|
||||
|
||||
const toSliceData = (bytes: Uint8Array): unknown => {
|
||||
switch (options.sliceDataForm) {
|
||||
case "view":
|
||||
return bytes;
|
||||
case "array":
|
||||
return Array.from(bytes);
|
||||
case "base64":
|
||||
return Buffer.from(bytes).toString("base64");
|
||||
default:
|
||||
return bytes.buffer;
|
||||
}
|
||||
};
|
||||
|
||||
const file = {
|
||||
sliceCount: slices.length,
|
||||
getSliceAsync: (index: number, callback: (result: unknown) => void) => {
|
||||
|
|
@ -20,9 +38,10 @@ function installOfficeFileMock(
|
|||
if (options.failSliceIndex === index) {
|
||||
callback({ status: "failed", error: { message: `อ่าน slice ${index} พัง (mock)` } });
|
||||
} else {
|
||||
// host จริงอาจส่งมาเป็น ArrayBuffer หรือ view — ทดสอบทั้งคู่
|
||||
const data = options.sliceDataForm === "view" ? slices[index] : slices[index].buffer;
|
||||
callback({ status: "succeeded", value: { index, data, size: slices[index].byteLength } });
|
||||
callback({
|
||||
status: "succeeded",
|
||||
value: { index, data: toSliceData(slices[index]), size: slices[index].byteLength },
|
||||
});
|
||||
}
|
||||
},
|
||||
closeAsync: (callback?: (result: unknown) => void) => {
|
||||
|
|
@ -81,6 +100,26 @@ describe("getCurrentDocumentFile", () => {
|
|||
expect(state.closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("slice มาเป็น byte array ธรรมดา number[] (Word บน Mac) ก็ประกอบได้", async () => {
|
||||
const state = installOfficeFileMock([bytes(10, 20), bytes(30, 40)], { sliceDataForm: "array" });
|
||||
|
||||
const blob = await getCurrentDocumentFile();
|
||||
|
||||
expect(blob.size).toBe(4);
|
||||
expect(state.closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("slice มาเป็น base64 string ก็ decode แล้วประกอบได้", async () => {
|
||||
const state = installOfficeFileMock([bytes(200, 100, 50), bytes(255)], {
|
||||
sliceDataForm: "base64",
|
||||
});
|
||||
|
||||
const blob = await getCurrentDocumentFile();
|
||||
|
||||
expect(blob.size).toBe(4);
|
||||
expect(state.closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("อ่าน slice กลางที่พัง → reject พร้อม error แต่ยังเรียก closeAsync เสมอ", async () => {
|
||||
const state = installOfficeFileMock([bytes(1), bytes(2), bytes(3)], { failSliceIndex: 1 });
|
||||
|
||||
|
|
|
|||
|
|
@ -35,20 +35,48 @@ function getFileAsync(): Promise<Office.File> {
|
|||
});
|
||||
}
|
||||
|
||||
/** host ส่ง slice มาเป็น ArrayBuffer หรือ view — รับทั้งคู่แล้วคืนเป็น BlobPart ตรงๆ */
|
||||
/**
|
||||
* host แต่ละตัวส่งข้อมูล slice มาคนละรูปแบบ (เอกสาร Office.js ระบบแค่ "byte array"):
|
||||
* - Word บน Windows ส่วนใหญ่ = ArrayBuffer / typed-array view
|
||||
* - Word บน Mac = base64 string หรือ byte array ธรรมดา (number[])
|
||||
* ปรับเป็น Uint8Array ให้หมดก่อนประกอบเป็น Blob
|
||||
*/
|
||||
export function sliceDataToBytes(data: unknown): Uint8Array<ArrayBuffer> {
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return new Uint8Array(data);
|
||||
}
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
const view = data as ArrayBufferView;
|
||||
return new Uint8Array(view.buffer as ArrayBuffer, view.byteOffset, view.byteLength);
|
||||
}
|
||||
if (typeof data === "string") {
|
||||
// base64 (บาง host ใส่ data-url prefix หรือขึ้นบรรทัดมาด้วย — ตัดทิ้งก่อน decode)
|
||||
const base64 = data.replace(/^data:[^,]*?;base64,/, "").replace(/\s/g, "");
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index++) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
if (Array.isArray(data) && (data.length === 0 || typeof data[0] === "number")) {
|
||||
return Uint8Array.from(data);
|
||||
}
|
||||
throw new Error(`รูปแบบข้อมูลของ slice ไม่รองรับ (ได้รับ ${typeof data})`);
|
||||
}
|
||||
|
||||
/** อ่าน slice หนึ่งชิ้นแล้ว normalize เป็น Uint8Array */
|
||||
function getSliceAsync(file: Office.File, sliceIndex: number): Promise<BlobPart> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
file.getSliceAsync(sliceIndex, (result) => {
|
||||
if (result.status === Office.AsyncResultStatus.Succeeded) {
|
||||
const data: unknown = result.value.data;
|
||||
if (data instanceof ArrayBuffer) {
|
||||
resolve(data);
|
||||
} else if (ArrayBuffer.isView(data)) {
|
||||
const view = data as ArrayBufferView;
|
||||
resolve(new Uint8Array(view.buffer as ArrayBuffer, view.byteOffset, view.byteLength));
|
||||
} else {
|
||||
reject(new Error("รูปแบบข้อมูลของ slice ไม่รองรับ (ต้องเป็น byte array)"));
|
||||
try {
|
||||
resolve(sliceDataToBytes(result.value.data));
|
||||
} catch (error) {
|
||||
reject(
|
||||
error instanceof Error ? error : new Error(`อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
reject(officeError(result, `อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`));
|
||||
|
|
|
|||
216
addin/src/services/versionFlow.test.ts
Normal file
216
addin/src/services/versionFlow.test.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import * as authService from "./authService";
|
||||
import { ADDIN_SIGNATURE_GUID, buildVersionHistoryXml } from "./metadataService";
|
||||
import { saveAndVersion, startVersioning } from "./versionFlow";
|
||||
import { installWordMock } from "../test/wordMock";
|
||||
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
|
||||
|
||||
/** mock Office.context.document (url สำหรับชื่อไฟล์ + getFileAsync สำหรับ fileService) */
|
||||
function installOfficeDocumentMock(url: string | undefined): void {
|
||||
const slice = new Uint8Array([1, 2, 3, 4]);
|
||||
(globalThis as unknown as Record<string, unknown>).Office = {
|
||||
FileType: { Compressed: "compressed", Text: "text" },
|
||||
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
|
||||
context: {
|
||||
document: {
|
||||
url,
|
||||
getFileAsync: (
|
||||
_fileType: unknown,
|
||||
_options: unknown,
|
||||
callback: (result: unknown) => void
|
||||
) => {
|
||||
callback({
|
||||
status: "succeeded",
|
||||
value: {
|
||||
sliceCount: 1,
|
||||
getSliceAsync: (index: number, cb: (result: unknown) => void) =>
|
||||
cb({ status: "succeeded", value: { index, data: slice.buffer } }),
|
||||
closeAsync: (cb?: (result: unknown) => void) => cb?.({ status: "succeeded" }),
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function seedSession(): void {
|
||||
authService.setSessionForTest(
|
||||
makeFakeJwt(Date.now() + 600_000, "user-sub", { preferred_username: "tester" }),
|
||||
"refresh-1",
|
||||
Date.now() + 600_000
|
||||
);
|
||||
}
|
||||
|
||||
function parseUploadCall(call: [string, { body?: FormData }]): { url: URL; form: FormData } {
|
||||
return { url: new URL(call[0]), form: call[1].body as FormData };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
seedSession();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
authService.signOut();
|
||||
});
|
||||
|
||||
describe("startVersioning (FR-1 / AC-5)", () => {
|
||||
it("stamp รอบแรกครบ → save → upload v1 พร้อม fields + ชื่อไฟล์จาก URL → เขียน XML part v1", async () => {
|
||||
const state = installWordMock({
|
||||
contentControls: [
|
||||
{ tag: "employee_name", text: "สมชาย ใจดี" },
|
||||
{ tag: "salary", text: "50000" },
|
||||
],
|
||||
});
|
||||
installOfficeDocumentMock("C:\\Users\\tester\\Documents\\สัญญาจ้าง.docx");
|
||||
const fetchMock = installFetchMock();
|
||||
fetchMock.mockImplementationOnce(async () =>
|
||||
mockResponse({ status: 201, body: { versionId: "backend-version-1", versionNumber: 1 } })
|
||||
);
|
||||
|
||||
const result = await startVersioning();
|
||||
|
||||
// ผลลัพธ์ผูกกันทั้งชุด
|
||||
expect(result).toEqual({
|
||||
documentId: result.documentId,
|
||||
versionId: "backend-version-1",
|
||||
versionNumber: 1,
|
||||
});
|
||||
expect(result.documentId).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
|
||||
// ครบทั้ง 4 properties + SavedByAddIn = GUID ของ add-in
|
||||
expect(state.properties.SavedByAddIn).toBe(ADDIN_SIGNATURE_GUID);
|
||||
expect(state.properties.DocumentTrackingId).toBe(result.documentId);
|
||||
expect(state.properties.LastVersionId).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
expect(state.properties.LastVersionTimestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
// เรียก native save หนึ่งครั้ง (FR-2 ขั้นตอนที่ 4)
|
||||
expect(state.saveCalls).toBe(1);
|
||||
|
||||
// upload ไปที่ documentId ที่ generate + แนบ fields + clientVersionId + ชื่อไฟล์จาก URL
|
||||
const { url, form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
|
||||
expect(`${url.origin}${url.pathname}`).toBe(
|
||||
`https://localhost:7232/api/documents/${result.documentId}/versions`
|
||||
);
|
||||
expect(String(form.get("clientVersionId"))).toBe(state.properties.LastVersionId);
|
||||
expect(JSON.parse(String(form.get("fields")))).toEqual({
|
||||
employee_name: "สมชาย ใจดี",
|
||||
salary: "50000",
|
||||
});
|
||||
expect(String((form.get("file") as File).name)).toBe("สัญญาจ้าง.docx");
|
||||
|
||||
// XML part มี entry v1 ผูกกับ clientVersionId เดียวกับ LastVersionId
|
||||
expect(state.xmlParts).toHaveLength(1);
|
||||
expect(state.xmlParts[0].xml).toContain('id="');
|
||||
expect(state.xmlParts[0].xml).toContain('number="1"');
|
||||
expect(state.xmlParts[0].xml).toContain('user="tester"');
|
||||
});
|
||||
|
||||
it("URL ไม่มี (Word Online) → ชื่อไฟล์ fallback", async () => {
|
||||
installWordMock({});
|
||||
installOfficeDocumentMock(undefined);
|
||||
const fetchMock = installFetchMock();
|
||||
fetchMock.mockImplementationOnce(async () =>
|
||||
mockResponse({ status: 201, body: { versionId: "v", versionNumber: 1 } })
|
||||
);
|
||||
|
||||
await startVersioning();
|
||||
|
||||
const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
|
||||
expect(String((form.get("file") as File).name)).toMatch(/\.docx$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveAndVersion (FR-2 / AC-7, 13)", () => {
|
||||
it("stamp → append XML entry (เดาเลขถัดไป) → save → upload พร้อม fields ล่าสุด", async () => {
|
||||
const state = installWordMock({
|
||||
properties: {
|
||||
SavedByAddIn: ADDIN_SIGNATURE_GUID,
|
||||
DocumentTrackingId: "11111111-1111-1111-1111-111111111111",
|
||||
LastVersionId: "old-client-version-id",
|
||||
},
|
||||
xmlParts: [
|
||||
buildVersionHistoryXml([
|
||||
{
|
||||
id: "old-client-version-id",
|
||||
number: 1,
|
||||
timestamp: "2026-08-19T09:00:00Z",
|
||||
user: "tester",
|
||||
},
|
||||
]),
|
||||
],
|
||||
contentControls: [{ tag: "salary", text: "60000" }],
|
||||
});
|
||||
installOfficeDocumentMock(undefined);
|
||||
const fetchMock = installFetchMock();
|
||||
fetchMock.mockImplementationOnce(async () =>
|
||||
mockResponse({ status: 201, body: { versionId: "backend-version-2", versionNumber: 2 } })
|
||||
);
|
||||
|
||||
const result = await saveAndVersion("11111111-1111-1111-1111-111111111111", 1);
|
||||
|
||||
expect(result).toEqual({ versionId: "backend-version-2", versionNumber: 2 });
|
||||
|
||||
// properties: tracking id คงเดิม, LastVersionId เปลี่ยนเป็น clientVersionId ใหม่
|
||||
expect(state.properties.DocumentTrackingId).toBe("11111111-1111-1111-1111-111111111111");
|
||||
const newLastVersionId = state.properties.LastVersionId;
|
||||
expect(newLastVersionId).not.toBe("old-client-version-id");
|
||||
expect(state.saveCalls).toBe(1);
|
||||
|
||||
// XML part: 2 entries เรียง v2 ก่อน v1
|
||||
expect(state.xmlParts).toHaveLength(1);
|
||||
const xml = state.xmlParts[0].xml;
|
||||
expect(xml).toContain('number="2"');
|
||||
expect(xml).toContain('number="1"');
|
||||
expect(xml.indexOf('number="2"')).toBeLessThan(xml.indexOf('number="1"'));
|
||||
|
||||
// upload ไปยัง document เดิม + fields ล่าสุด
|
||||
const { url, form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
|
||||
expect(url.pathname).toBe("/api/documents/11111111-1111-1111-1111-111111111111/versions");
|
||||
expect(String(form.get("clientVersionId"))).toBe(newLastVersionId);
|
||||
expect(JSON.parse(String(form.get("fields")))).toEqual({ salary: "60000" });
|
||||
});
|
||||
|
||||
it("backend ตอบเลข version ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรงเลขจริง", async () => {
|
||||
const state = installWordMock({
|
||||
properties: {
|
||||
SavedByAddIn: ADDIN_SIGNATURE_GUID,
|
||||
DocumentTrackingId: "11111111-1111-1111-1111-111111111111",
|
||||
LastVersionId: "old",
|
||||
},
|
||||
xmlParts: [buildVersionHistoryXml([{ id: "old", number: 1, timestamp: "t", user: "u" }])],
|
||||
});
|
||||
installOfficeDocumentMock(undefined);
|
||||
const fetchMock = installFetchMock();
|
||||
fetchMock.mockImplementationOnce(async () =>
|
||||
mockResponse({ status: 201, body: { versionId: "backend-version-5", versionNumber: 5 } })
|
||||
);
|
||||
|
||||
const result = await saveAndVersion("11111111-1111-1111-1111-111111111111", 1);
|
||||
|
||||
expect(result.versionNumber).toBe(5);
|
||||
// entry ใหม่ถูกแก้จาก 2 (ที่เดา) เป็น 5 (ของจริง)
|
||||
const xml = state.xmlParts[0].xml;
|
||||
expect(xml).toContain('number="5"');
|
||||
expect(xml).not.toContain('number="2"');
|
||||
expect(xml).toContain('number="1"');
|
||||
});
|
||||
|
||||
it("XML part เขียนไม่ได้ (known bug บน Mac) → flow ยังสำเร็จต่อ (AC-10)", async () => {
|
||||
installWordMock({
|
||||
properties: {
|
||||
SavedByAddIn: ADDIN_SIGNATURE_GUID,
|
||||
DocumentTrackingId: "11111111-1111-1111-1111-111111111111",
|
||||
},
|
||||
failXmlAdd: true,
|
||||
});
|
||||
installOfficeDocumentMock(undefined);
|
||||
const fetchMock = installFetchMock();
|
||||
fetchMock.mockImplementationOnce(async () =>
|
||||
mockResponse({ status: 201, body: { versionId: "backend-version-2", versionNumber: 2 } })
|
||||
);
|
||||
|
||||
await expect(saveAndVersion("11111111-1111-1111-1111-111111111111", 1)).resolves.toEqual({
|
||||
versionId: "backend-version-2",
|
||||
versionNumber: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
159
addin/src/services/versionFlow.ts
Normal file
159
addin/src/services/versionFlow.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/* global Word, Office */
|
||||
|
||||
import * as authService from "./authService";
|
||||
import { getCurrentDocumentFile } from "./fileService";
|
||||
import {
|
||||
generateGuid,
|
||||
readContentControlFields,
|
||||
readVersionHistoryFromXml,
|
||||
stampTrackedProperties,
|
||||
writeVersionHistoryToXml,
|
||||
XmlVersionEntry,
|
||||
} from "./metadataService";
|
||||
import { uploadVersion } from "./versioningService";
|
||||
|
||||
/**
|
||||
* Orchestration ของ flow หลักสองอันของ add-in (แยกจาก UI เพื่อ unit test ได้):
|
||||
*
|
||||
* - `startVersioning` (FR-1 "Start Versioning This Document") — เอกสารใหม่ที่ยังไม่ถูก track
|
||||
* - `saveAndVersion` (FR-2) — ไล่ตามลำดับใน spec แบบเคร่งครัด:
|
||||
* stamp properties → XML part (delete-then-add, fail ได้เงียบๆ) → document.save()
|
||||
* → ดึงไฟล์ + extract fields (FR-11) → POST version → คืนผลให้ UI แสดง/refresh history
|
||||
*
|
||||
* หมายเหตุค่า id ใน metadata: `LastVersionId` และ `<Version id>` ใน XML part เก็บ
|
||||
* **clientVersionId** (GUID ที่ client generate) เพราะต้อง stamp ลงไฟล์ *ก่อน* upload —
|
||||
* versionId ของ backend จะได้มาหลัง upload ซึ่งไฟล์ถูก capture ไปแล้ว
|
||||
* (backend คือ source of truth ของ id จริงอยู่ดี)
|
||||
*/
|
||||
|
||||
export interface StartVersioningResult {
|
||||
documentId: string;
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
}
|
||||
|
||||
export interface SaveAndVersionResult {
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
}
|
||||
|
||||
/** เลข version ถัดไปตามที่ client เดา (history ล่าสุด + 1) — ใช้เขียน XML part ก่อนรู้ค่าจริงจาก backend */
|
||||
function predictNextVersionNumber(latestVersionNumber: number): number {
|
||||
return latestVersionNumber + 1;
|
||||
}
|
||||
|
||||
/** ชื่อไฟล์จาก URL ของ document ถ้าดึงได้ (desktop มักเป็น path จริง) — ใช้เป็นชื่อตอน upload */
|
||||
function guessDocumentFileName(): string | undefined {
|
||||
try {
|
||||
const url = Office.context.document.url;
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
const name = url.split(/[\\/]/).pop();
|
||||
if (!name) {
|
||||
return undefined;
|
||||
}
|
||||
return name.toLowerCase().endsWith(".docx") ? name : `${name}.docx`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** `context.document.save(Word.SaveBehavior.save)` — trigger native save ตาม FR-2 ขั้นตอนที่ 4 */
|
||||
async function saveDocument(): Promise<void> {
|
||||
await Word.run(async (context) => {
|
||||
context.document.save(Word.SaveBehavior.save);
|
||||
await context.sync();
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadCurrentFile(
|
||||
documentId: string,
|
||||
clientVersionId: string,
|
||||
fields: Record<string, string>
|
||||
): Promise<{ versionId: string; versionNumber: number }> {
|
||||
const file = await getCurrentDocumentFile();
|
||||
return uploadVersion(documentId, file, clientVersionId, fields, guessDocumentFileName());
|
||||
}
|
||||
|
||||
/** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1 */
|
||||
export async function startVersioning(
|
||||
onProgress?: (message: string) => void
|
||||
): Promise<StartVersioningResult> {
|
||||
const documentId = generateGuid();
|
||||
const clientVersionId = generateGuid();
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// 1) stamp รอบแรก — ได้ทั้ง SavedByAddIn และ DocumentTrackingId ใหม่
|
||||
onProgress?.("กำลังบันทึก metadata ของเอกสาร…");
|
||||
await stampTrackedProperties({
|
||||
documentTrackingId: documentId,
|
||||
lastVersionId: clientVersionId,
|
||||
lastVersionTimestamp: timestamp,
|
||||
});
|
||||
|
||||
// 2) XML part (secondary — fail ได้เงียบๆ)
|
||||
onProgress?.("กำลังอัปเดต version history ในไฟล์…");
|
||||
await writeVersionHistoryToXml([
|
||||
{ id: clientVersionId, number: 1, timestamp, user: authService.getUsername() ?? "unknown" },
|
||||
]);
|
||||
|
||||
// 3) native save แล้ว capture ไฟล์ + fields
|
||||
onProgress?.("กำลังบันทึกไฟล์…");
|
||||
await saveDocument();
|
||||
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
|
||||
const fields = await readContentControlFields();
|
||||
|
||||
// 4) upload version แรก
|
||||
onProgress?.("กำลังอัปโหลด version 1…");
|
||||
const result = await uploadCurrentFile(documentId, clientVersionId, fields);
|
||||
return { documentId, ...result };
|
||||
}
|
||||
|
||||
/** FR-2 — Save & Version ของเอกสารที่ track อยู่แล้ว (เรียกหลังผู้ใช้กดยืนยันใน dialog แล้ว) */
|
||||
export async function saveAndVersion(
|
||||
documentId: string,
|
||||
latestVersionNumber: number,
|
||||
onProgress?: (message: string) => void
|
||||
): Promise<SaveAndVersionResult> {
|
||||
const clientVersionId = generateGuid();
|
||||
const timestamp = new Date().toISOString();
|
||||
const predictedNumber = predictNextVersionNumber(latestVersionNumber);
|
||||
const user = authService.getUsername() ?? "unknown";
|
||||
|
||||
// 1) stamp properties (SavedByAddIn + DocumentTrackingId ถูก stamp ไว้แล้วจากรอบแรก — เขียนทับไม่เสียหาย)
|
||||
onProgress?.("กำลังบันทึก metadata ของ version ใหม่…");
|
||||
await stampTrackedProperties({
|
||||
lastVersionId: clientVersionId,
|
||||
lastVersionTimestamp: timestamp,
|
||||
});
|
||||
|
||||
// 2) XML part: เพิ่ม entry ของ version ใหม่ไว้ต้นสุด=ใหม่→เก่า ตามรูปใน spec (เดาเลขก่อน upload) — fail ได้เงียบๆ
|
||||
onProgress?.("กำลังอัปเดต version history ในไฟล์…");
|
||||
const entries = await readVersionHistoryFromXml();
|
||||
entries.unshift({ id: clientVersionId, number: predictedNumber, timestamp, user });
|
||||
await writeVersionHistoryToXml(entries);
|
||||
|
||||
// 3) native save → capture ไฟล์ + fields ปัจจุบัน (FR-11)
|
||||
onProgress?.("กำลังบันทึกไฟล์…");
|
||||
await saveDocument();
|
||||
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
|
||||
const fields = await readContentControlFields();
|
||||
|
||||
// 4) upload
|
||||
onProgress?.("กำลังอัปโหลด version ใหม่…");
|
||||
const result = await uploadCurrentFile(documentId, clientVersionId, fields);
|
||||
|
||||
// 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort)
|
||||
if (result.versionNumber !== predictedNumber) {
|
||||
const corrected = await readVersionHistoryFromXml();
|
||||
const entry = corrected.find((item: XmlVersionEntry) => item.id === clientVersionId);
|
||||
if (entry) {
|
||||
entry.number = result.versionNumber;
|
||||
corrected.sort((a, b) => b.number - a.number); // คงลำดับใหม่→เก่าหลังแก้เลข
|
||||
await writeVersionHistoryToXml(corrected);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -42,6 +42,26 @@ export function buildDownloadUrl(documentId: string, versionId: string): string
|
|||
return `${config.apiBaseUrl}/api/documents/${documentId}/versions/${versionId}/download`;
|
||||
}
|
||||
|
||||
export interface VersionDownloadLink {
|
||||
url: string;
|
||||
/** ISO-8601 เวลาที่ URL หมดอายุ */
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FR-7 — ขอ presigned URL อายุสั้นของ version หนึ่ง เพื่อเปิดในเบราว์เซอร์ของผู้ใช้
|
||||
* (webview ของ Word ทิ้ง blob download เงียบๆ — ให้เบราว์เซอร์จริงจัดการแทน)
|
||||
*/
|
||||
export async function getDownloadLink(
|
||||
documentId: string,
|
||||
versionId: string
|
||||
): Promise<VersionDownloadLink> {
|
||||
return apiGetJson<VersionDownloadLink>(
|
||||
`/api/documents/${documentId}/versions/${versionId}/download-link`,
|
||||
"ขอลิงก์ดาวน์โหลดไม่สำเร็จ"
|
||||
);
|
||||
}
|
||||
|
||||
/** FR-7 — ดาวน์โหลดไฟล์ของ version หนึ่งเป็น Blob (ต้องแนบ Bearer จึงต้อง fetch แทน link ตรง) */
|
||||
export async function downloadVersion(documentId: string, versionId: string): Promise<Blob> {
|
||||
const response = await apiFetch(buildDownloadUrl(documentId, versionId));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue