implement phase 5 และ fix bugs

This commit is contained in:
Suphonchai Phoonsawat 2026-08-19 23:06:57 +07:00
parent f4b1c3e14b
commit 1bb850c6b8
40 changed files with 1507 additions and 429 deletions

View file

@ -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();

View file

@ -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);
});

View file

@ -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
// ---------------------------------------------------------------------------

View 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;
}

View file

@ -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 });

View file

@ -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} ไม่สำเร็จ`));

View 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,
});
});
});

View 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;
}

View file

@ -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));