Phase 11: version PDF (FR-15)
- เลข version ใหม่: เว็บ-generate = v0 ฉบับตั้งต้น (ไม่มี PDF), add-in save (Start Versioning + Save & Version) = v1+ มี PDF เสมอ - PDF ผลิตจาก Word จริงผ่าน task pane เท่านั้น (getFileAsync Pdf + runtime guard, manifest ไม่เพิ่ม requirement); capture/upload เป็น best-effort — docx ผ่านแม้ PDF ล้ม + MessageBar เตือนตาม hasPdf - Backend: DocumentVersion.PdfObjectKey (nullable, migration AddVersionPdf) + sibling .pdf ใน bucket docx-versions · multipart part pdf (ผิด type → 400) · /download + /download-link รับ ?format=pdf (ไม่มี → 404) · presigned ฝังชื่อไฟล์ไทย + _vN (RFC 5987) - Web: เปิดไฟล์แทน blob-download — PDF แท็บใหม่, docx ผ่าน ms-word:ofe|u| พร้อม heuristic fallback ลิงก์สำรอง (useVersionOpen, versionDownload) · ปุ่ม "เปิดใน Word" ที่หน้า success ของฟอร์มสร้างเอกสาร - Spec v7 + CONTEXT.md + ADR 0001 + แผน docs/plans/version-pdf.md - Automated QA: backend 68 / add-in 63 / web 55 ผ่าน · manual acceptance ผ่านบน Word for Mac (2026-09-08) รวม fidelity + track changes — ค้างเฉพาะ Windows / Word on the web Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
618328ffa7
commit
39d610a781
39 changed files with 1387 additions and 212 deletions
|
|
@ -1,7 +1,13 @@
|
|||
import { getCurrentDocumentFile, DOCX_CONTENT_TYPE } from "./fileService";
|
||||
import {
|
||||
getCurrentDocumentFile,
|
||||
getCurrentDocumentPdf,
|
||||
DOCX_CONTENT_TYPE,
|
||||
PDF_CONTENT_TYPE,
|
||||
} from "./fileService";
|
||||
|
||||
interface FileMockState {
|
||||
getFileCalls: number;
|
||||
fileTypes: unknown[];
|
||||
requestedSlices: number[];
|
||||
closeCalls: number;
|
||||
}
|
||||
|
|
@ -12,11 +18,17 @@ function installOfficeFileMock(
|
|||
options: {
|
||||
failSliceIndex?: number;
|
||||
failClose?: boolean;
|
||||
unsupported?: boolean;
|
||||
/** รูปร่างของ slice.data ที่ host จริงส่งกลับ — ต่างกันตามแพลตฟอร์ม (Win=ArrayBuffer, Mac=string/array) */
|
||||
sliceDataForm?: "buffer" | "view" | "array" | "base64";
|
||||
} = {}
|
||||
): FileMockState {
|
||||
const state: FileMockState = { getFileCalls: 0, requestedSlices: [], closeCalls: 0 };
|
||||
const state: FileMockState = {
|
||||
fileTypes: [],
|
||||
getFileCalls: 0,
|
||||
requestedSlices: [],
|
||||
closeCalls: 0,
|
||||
};
|
||||
|
||||
const toSliceData = (bytes: Uint8Array): unknown => {
|
||||
switch (options.sliceDataForm) {
|
||||
|
|
@ -51,9 +63,10 @@ function installOfficeFileMock(
|
|||
};
|
||||
|
||||
(globalThis as unknown as Record<string, unknown>).Office = {
|
||||
FileType: { Compressed: "compressed", Text: "text" },
|
||||
FileType: { Compressed: "compressed", Pdf: "pdf", Text: "text" },
|
||||
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
|
||||
context: {
|
||||
requirements: { isSetSupported: () => !options.unsupported },
|
||||
document: {
|
||||
getFileAsync: (
|
||||
_fileType: unknown,
|
||||
|
|
@ -61,6 +74,7 @@ function installOfficeFileMock(
|
|||
callback: (result: unknown) => void
|
||||
) => {
|
||||
state.getFileCalls++;
|
||||
state.fileTypes.push(_fileType);
|
||||
callback({ status: "succeeded", value: file });
|
||||
},
|
||||
},
|
||||
|
|
@ -135,3 +149,26 @@ describe("getCurrentDocumentFile", () => {
|
|||
expect(blob.size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentDocumentPdf", () => {
|
||||
it("captures PDF slices and closes the file", async () => {
|
||||
const state = installOfficeFileMock([bytes(37, 80), bytes(68, 70)], {
|
||||
sliceDataForm: "base64",
|
||||
});
|
||||
const pdf = await getCurrentDocumentPdf();
|
||||
expect(pdf.type).toBe(PDF_CONTENT_TYPE);
|
||||
expect(pdf.size).toBe(4);
|
||||
expect(state.fileTypes).toEqual(["pdf"]);
|
||||
expect(state.closeCalls).toBe(1);
|
||||
});
|
||||
it("rejects unsupported hosts without opening a file", async () => {
|
||||
const state = installOfficeFileMock([], { unsupported: true });
|
||||
await expect(getCurrentDocumentPdf()).rejects.toThrow();
|
||||
expect(state.getFileCalls).toBe(0);
|
||||
});
|
||||
it("closes PDF even when slice reading fails", async () => {
|
||||
const state = installOfficeFileMock([bytes(1)], { failSliceIndex: 0 });
|
||||
await expect(getCurrentDocumentPdf()).rejects.toThrow();
|
||||
expect(state.closeCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,26 +9,24 @@ export const DOCX_CONTENT_TYPE =
|
|||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
|
||||
/** ขนาด slice ต่อครั้ง — 1MB กันกิน memory ยักษ์ในเครื่องรายงานน้อย */
|
||||
export const PDF_CONTENT_TYPE = "application/pdf";
|
||||
|
||||
const SLICE_SIZE = 1024 * 1024;
|
||||
|
||||
function officeError(result: Office.AsyncResult<unknown>, fallback: string): Error {
|
||||
return new Error(result.error?.message || fallback);
|
||||
}
|
||||
|
||||
function getFileAsync(): Promise<Office.File> {
|
||||
function getFileAsync(fileType: Office.FileType): Promise<Office.File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
Office.context.document.getFileAsync(
|
||||
Office.FileType.Compressed,
|
||||
{ sliceSize: SLICE_SIZE },
|
||||
(result) => {
|
||||
if (result.status === Office.AsyncResultStatus.Succeeded) {
|
||||
resolve(result.value);
|
||||
} else {
|
||||
reject(officeError(result, "เปิดไฟล์เอกสารไม่สำเร็จ"));
|
||||
}
|
||||
Office.context.document.getFileAsync(fileType, { sliceSize: SLICE_SIZE }, (result) => {
|
||||
if (result.status === Office.AsyncResultStatus.Succeeded) {
|
||||
resolve(result.value);
|
||||
} else {
|
||||
reject(officeError(result, "เปิดไฟล์เอกสารไม่สำเร็จ"));
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error("เปิดไฟล์เอกสารไม่สำเร็จ"));
|
||||
}
|
||||
|
|
@ -104,15 +102,26 @@ function closeFileQuietly(file: Office.File): Promise<void> {
|
|||
* ประกอบไฟล์ทั้งไฟล์เป็น Blob — อ่าน slice ทีละชิ้นตามลำดับ
|
||||
* เรียก closeAsync เสมอ (สำเร็จหรือ fail ก็ตาม)
|
||||
*/
|
||||
export async function getCurrentDocumentFile(): Promise<Blob> {
|
||||
const file = await getFileAsync();
|
||||
async function readFileAsBlob(fileType: Office.FileType, contentType: string): Promise<Blob> {
|
||||
const file = await getFileAsync(fileType);
|
||||
try {
|
||||
const chunks: BlobPart[] = [];
|
||||
for (let index = 0; index < file.sliceCount; index++) {
|
||||
chunks.push(await getSliceAsync(file, index));
|
||||
}
|
||||
return new Blob(chunks, { type: DOCX_CONTENT_TYPE });
|
||||
return new Blob(chunks, { type: contentType });
|
||||
} finally {
|
||||
await closeFileQuietly(file);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentDocumentFile(): Promise<Blob> {
|
||||
return readFileAsBlob(Office.FileType.Compressed, DOCX_CONTENT_TYPE);
|
||||
}
|
||||
|
||||
export async function getCurrentDocumentPdf(): Promise<Blob> {
|
||||
if (!Office.context.requirements.isSetSupported("PdfFile", "1.1")) {
|
||||
throw new Error("Word นี้ไม่รองรับการสร้าง PDF");
|
||||
}
|
||||
return readFileAsBlob(Office.FileType.Pdf, PDF_CONTENT_TYPE);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,17 @@ 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 {
|
||||
function installOfficeDocumentMock(
|
||||
url: string | undefined,
|
||||
options: { failPdf?: boolean; emptyPdf?: boolean } = {}
|
||||
): string[] {
|
||||
const requested: string[] = [];
|
||||
const slice = new Uint8Array([1, 2, 3, 4]);
|
||||
(globalThis as unknown as Record<string, unknown>).Office = {
|
||||
FileType: { Compressed: "compressed", Text: "text" },
|
||||
FileType: { Compressed: "compressed", Pdf: "pdf", Text: "text" },
|
||||
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
|
||||
context: {
|
||||
requirements: { isSetSupported: () => true },
|
||||
document: {
|
||||
url,
|
||||
getFileAsync: (
|
||||
|
|
@ -18,10 +23,15 @@ function installOfficeDocumentMock(url: string | undefined): void {
|
|||
_options: unknown,
|
||||
callback: (result: unknown) => void
|
||||
) => {
|
||||
requested.push(String(_fileType));
|
||||
if (_fileType === "pdf" && options.failPdf) {
|
||||
callback({ status: "failed", error: { message: "PDF unavailable" } });
|
||||
return;
|
||||
}
|
||||
callback({
|
||||
status: "succeeded",
|
||||
value: {
|
||||
sliceCount: 1,
|
||||
sliceCount: _fileType === "pdf" && options.emptyPdf ? 0 : 1,
|
||||
getSliceAsync: (index: number, cb: (result: unknown) => void) =>
|
||||
cb({ status: "succeeded", value: { index, data: slice.buffer } }),
|
||||
closeAsync: (cb?: (result: unknown) => void) => cb?.({ status: "succeeded" }),
|
||||
|
|
@ -31,6 +41,7 @@ function installOfficeDocumentMock(url: string | undefined): void {
|
|||
},
|
||||
},
|
||||
};
|
||||
return requested;
|
||||
}
|
||||
|
||||
function seedSession(): void {
|
||||
|
|
@ -266,3 +277,35 @@ describe("saveAndVersion (FR-2 / AC-7, 13)", () => {
|
|||
expect(form.get("message")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(["start", "save"])("FR-15 %s", (flow) => {
|
||||
it.each(["success", "failure", "empty", "storage-failure"])(
|
||||
"PDF %s preserves docx upload",
|
||||
async (mode) => {
|
||||
installWordMock({ properties: { DocumentTrackingId: "doc" } });
|
||||
const requested = installOfficeDocumentMock(undefined, {
|
||||
failPdf: mode === "failure",
|
||||
emptyPdf: mode === "empty",
|
||||
});
|
||||
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const fetchMock = installFetchMock();
|
||||
fetchMock.mockImplementationOnce(async () =>
|
||||
mockResponse({
|
||||
status: 201,
|
||||
body: { versionId: "v", versionNumber: 1, hasPdf: mode === "success" },
|
||||
})
|
||||
);
|
||||
const result = flow === "start" ? await startVersioning() : await saveAndVersion("doc", 0);
|
||||
const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
|
||||
expect(requested).toEqual(["compressed", "pdf"]);
|
||||
expect(form.get("file")).toBeInstanceOf(Blob);
|
||||
if (mode === "success" || mode === "storage-failure") {
|
||||
expect((form.get("pdf") as File).name).toMatch(/\.pdf$/);
|
||||
expect((form.get("pdf") as File).type).toBe("application/pdf");
|
||||
} else expect(form.get("pdf")).toBeNull();
|
||||
expect(result.hasPdf).toBe(mode === "success");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
warn.mockRestore();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* global Word, Office */
|
||||
/* global Word, Office, console */
|
||||
|
||||
import * as authService from "./authService";
|
||||
import { getCurrentDocumentFile } from "./fileService";
|
||||
import { getCurrentDocumentFile, getCurrentDocumentPdf } from "./fileService";
|
||||
import {
|
||||
generateGuid,
|
||||
readContentControlFields,
|
||||
|
|
@ -27,12 +27,14 @@ import { uploadVersion } from "./versioningService";
|
|||
*/
|
||||
|
||||
export interface StartVersioningResult {
|
||||
hasPdf: boolean;
|
||||
documentId: string;
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
}
|
||||
|
||||
export interface SaveAndVersionResult {
|
||||
hasPdf: boolean;
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
}
|
||||
|
|
@ -71,10 +73,29 @@ async function uploadCurrentFile(
|
|||
documentId: string,
|
||||
clientVersionId: string,
|
||||
fields: Record<string, string>,
|
||||
message?: string
|
||||
): Promise<{ versionId: string; versionNumber: number }> {
|
||||
message?: string,
|
||||
onProgress?: (progress: string) => void
|
||||
): Promise<SaveAndVersionResult> {
|
||||
const file = await getCurrentDocumentFile();
|
||||
return uploadVersion(documentId, file, clientVersionId, fields, guessDocumentFileName(), message);
|
||||
let pdf: Blob | undefined;
|
||||
onProgress?.("กำลังสร้างสำเนา PDF…");
|
||||
try {
|
||||
pdf = await getCurrentDocumentPdf();
|
||||
if (pdf.size === 0) throw new Error("Word คืนไฟล์ PDF ว่าง");
|
||||
} catch (error) {
|
||||
console.warn("PDF capture failed:", error);
|
||||
pdf = undefined;
|
||||
}
|
||||
onProgress?.("กำลังอัปโหลด version…");
|
||||
return uploadVersion(
|
||||
documentId,
|
||||
file,
|
||||
clientVersionId,
|
||||
fields,
|
||||
guessDocumentFileName(),
|
||||
message,
|
||||
pdf
|
||||
);
|
||||
}
|
||||
|
||||
/** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1
|
||||
|
|
@ -109,7 +130,7 @@ export async function startVersioning(
|
|||
|
||||
// 4) upload version แรก
|
||||
onProgress?.("กำลังอัปโหลด version 1…");
|
||||
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message);
|
||||
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress);
|
||||
return { documentId, ...result };
|
||||
}
|
||||
|
||||
|
|
@ -147,7 +168,7 @@ export async function saveAndVersion(
|
|||
|
||||
// 4) upload
|
||||
onProgress?.("กำลังอัปโหลด version ใหม่…");
|
||||
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message);
|
||||
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress);
|
||||
|
||||
// 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort)
|
||||
if (result.versionNumber !== predictedNumber) {
|
||||
|
|
|
|||
33
addin/src/services/versioningService.test.ts
Normal file
33
addin/src/services/versioningService.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { getDownloadLink, uploadVersion } from "./versioningService";
|
||||
import { apiGetJson, apiSendExpectJson } from "./apiClient";
|
||||
|
||||
jest.mock("./apiClient", () => ({ apiGetJson: jest.fn(), apiSendExpectJson: jest.fn() }));
|
||||
|
||||
it("requests PDF download-link and keeps docx as default", async () => {
|
||||
await getDownloadLink("d", "v", "pdf");
|
||||
expect(apiGetJson).toHaveBeenLastCalledWith(
|
||||
"/api/documents/d/versions/v/download-link?format=pdf",
|
||||
expect.any(String)
|
||||
);
|
||||
await getDownloadLink("d", "v");
|
||||
expect(apiGetJson).toHaveBeenLastCalledWith(
|
||||
"/api/documents/d/versions/v/download-link",
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
|
||||
it("uploads optional PDF alongside the docx in one request", async () => {
|
||||
await uploadVersion(
|
||||
"d",
|
||||
new Blob(["docx"]),
|
||||
"v",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
new Blob(["pdf"], { type: "application/pdf" })
|
||||
);
|
||||
const form = (apiSendExpectJson as jest.Mock).mock.calls[0][1].body as FormData;
|
||||
expect((form.get("pdf") as File).name).toBe("d.pdf");
|
||||
expect((form.get("pdf") as File).type).toBe("application/pdf");
|
||||
expect((form.get("file") as File).name).toBe("d.docx");
|
||||
});
|
||||
|
|
@ -16,11 +16,13 @@ export async function uploadVersion(
|
|||
clientVersionId: string,
|
||||
fields?: Record<string, string>,
|
||||
fileName?: string,
|
||||
message?: string
|
||||
message?: string,
|
||||
pdf?: Blob
|
||||
): Promise<UploadVersionResult> {
|
||||
const form = new FormData();
|
||||
form.append("file", file, fileName || `${documentId}.docx`);
|
||||
form.append("clientVersionId", clientVersionId);
|
||||
if (pdf) form.append("pdf", pdf, `${documentId}.pdf`);
|
||||
if (fields && Object.keys(fields).length > 0) {
|
||||
form.append("fields", JSON.stringify(fields));
|
||||
}
|
||||
|
|
@ -44,8 +46,12 @@ export async function getVersions(documentId: string): Promise<VersionRecord[]>
|
|||
}
|
||||
|
||||
/** FR-7 — URL ของ download endpoint (ผ่าน backend proxy เท่านั้น — MinIO ไม่ถูก expose) */
|
||||
export function buildDownloadUrl(documentId: string, versionId: string): string {
|
||||
return `${config.apiBaseUrl}/api/documents/${documentId}/versions/${versionId}/download`;
|
||||
export function buildDownloadUrl(
|
||||
documentId: string,
|
||||
versionId: string,
|
||||
format?: "docx" | "pdf"
|
||||
): string {
|
||||
return `${config.apiBaseUrl}/api/documents/${documentId}/versions/${versionId}/download${format === "pdf" ? "?format=pdf" : ""}`;
|
||||
}
|
||||
|
||||
export interface VersionDownloadLink {
|
||||
|
|
@ -60,17 +66,22 @@ export interface VersionDownloadLink {
|
|||
*/
|
||||
export async function getDownloadLink(
|
||||
documentId: string,
|
||||
versionId: string
|
||||
versionId: string,
|
||||
format?: "docx" | "pdf"
|
||||
): Promise<VersionDownloadLink> {
|
||||
return apiGetJson<VersionDownloadLink>(
|
||||
`/api/documents/${documentId}/versions/${versionId}/download-link`,
|
||||
`/api/documents/${documentId}/versions/${versionId}/download-link${format === "pdf" ? "?format=pdf" : ""}`,
|
||||
"ขอลิงก์ดาวน์โหลดไม่สำเร็จ"
|
||||
);
|
||||
}
|
||||
|
||||
/** FR-7 — ดาวน์โหลดไฟล์ของ version หนึ่งเป็น Blob (ต้องแนบ Bearer จึงต้อง fetch แทน link ตรง) */
|
||||
export async function downloadVersion(documentId: string, versionId: string): Promise<Blob> {
|
||||
const response = await apiFetch(buildDownloadUrl(documentId, versionId));
|
||||
export async function downloadVersion(
|
||||
documentId: string,
|
||||
versionId: string,
|
||||
format?: "docx" | "pdf"
|
||||
): Promise<Blob> {
|
||||
const response = await apiFetch(buildDownloadUrl(documentId, versionId, format));
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue