Refactor document versioning to extract fields from uploaded .docx files on the server side
- Removed the `fields` parameter from the version upload form, as it is now extracted from the uploaded .docx file using the `extractContentControls` function. - Updated related types and interfaces to reflect the new structure of version fields. - Modified the OpenAPI specification to document the changes in the versioning process. - Adjusted tests to ensure compliance with the new behavior, including validation for the absence of the `fields` parameter. - Implemented a new function `extractDocumentSnapshot` to handle the extraction of content controls and document properties from .docx files. - Updated metadata and versioning services to align with the new extraction logic.
This commit is contained in:
parent
bc61f0fd2e
commit
cdae1ceedd
17 changed files with 380 additions and 179 deletions
|
|
@ -16,13 +16,36 @@ export interface FieldSnapshotEntry {
|
|||
missing: boolean;
|
||||
}
|
||||
|
||||
/** fields ของ version รูปแบบปัจจุบัน — **ก้อนเดียวกับ return ของ `/api/template/extract`**
|
||||
* ที่ server แกะจากไฟล์ .docx ฝั่ง server ตอนอัปโหลด (2026-10-30) · ค่าวันที่ใน
|
||||
* properties มาเป็น ISO string ตาม DTO ของกลุ่ม docver */
|
||||
export interface ExtractSnapshotFields {
|
||||
/** ค่า Content Control `{ [tag]: text }` */
|
||||
cc: Record<string, string>;
|
||||
/** ข้อความใน bookmark `{ [name]: text }` */
|
||||
bookmark: Record<string, string>;
|
||||
/** document properties (standard/extended/custom) */
|
||||
properties: {
|
||||
standard?: Record<string, string | undefined>;
|
||||
extended?: Record<string, number | string | undefined>;
|
||||
custom?: Record<string, string | number | boolean | null>;
|
||||
};
|
||||
/** ผลตรวจ ticket ที่ stamp มากับไฟล์ (exp + HmacSha256 ด้วย secret ฝั่ง server) */
|
||||
verify: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* snapshot fields ต่อ version — backend เก็บ 2 รูปแบบ:
|
||||
* snapshot fields ต่อ version — backend เก็บ 3 รูปแบบ:
|
||||
* - ปัจจุบัน (2026-10-30): ก้อน extract snapshot `{cc, bookmark, properties, verify}`
|
||||
* เหมือน return ของ /api/template/extract (server แกะจากไฟล์เอง)
|
||||
* - เอกสารผูก template: `{ [fieldKey]: { value, missing } }`
|
||||
* - เอกสารไม่ผูก template (track เองจาก add-in): map ดิบ `{ [tag]: text }`
|
||||
* UI ต้อง handle ได้ทั้งคู่ (ใช้ readSnapshotField ช่วยอ่าน)
|
||||
* UI ต้อง handle ได้ทั้งหมด (ใช้ readSnapshotField ช่วยอ่าน)
|
||||
*/
|
||||
export type FieldsSnapshot = Record<string, FieldSnapshotEntry> | Record<string, string>;
|
||||
export type FieldsSnapshot =
|
||||
| ExtractSnapshotFields
|
||||
| Record<string, FieldSnapshotEntry>
|
||||
| Record<string, string>;
|
||||
|
||||
/** item ของ GET /api/documents/{documentId}/versions */
|
||||
export interface VersionRecord {
|
||||
|
|
@ -41,7 +64,8 @@ export interface VersionRecord {
|
|||
message: string | null;
|
||||
}
|
||||
|
||||
/** อ่านค่า + สถานะ missing ของ field หนึ่งจาก snapshot ไม่ว่าจะเก็บรูปแบบไหน — ไม่มี field นี้คืน null */
|
||||
/** อ่านค่า + สถานะ missing ของ field หนึ่งจาก snapshot ไม่ว่าจะเก็บรูปแบบไหน — ไม่มี field นี้คืน null
|
||||
* รูปแบบ extract snapshot (2026-10-30) ค่า CC อยู่ใต้ `cc` — อ่านซ้ำเข้า map ดิบรอบเดียวกัน */
|
||||
export function readSnapshotField(
|
||||
snapshot: FieldsSnapshot | null,
|
||||
key: string
|
||||
|
|
@ -49,6 +73,13 @@ export function readSnapshotField(
|
|||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
// Record ธรรมดาไม่ถูก `in` ตัดออกจาก union (มี index signature ครอบทุก key) — เช็คแล้ว
|
||||
// บอกชนิดตรง ๆ ว่าเป็นก้อน extract snapshot (key "cc" ชนกับ tag ชื่อเดียวกันยอมรับเป็น
|
||||
// edge case ของการเดารูปจากข้อมูลเก่า)
|
||||
if ("cc" in snapshot) {
|
||||
const cc = (snapshot as ExtractSnapshotFields).cc;
|
||||
return readSnapshotField(cc, key);
|
||||
}
|
||||
const raw = snapshot[key];
|
||||
if (raw === undefined) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
* - Custom XML Part (ns `urn:myaddin:versioning`) = secondary เท่านั้น — backend คือ source of truth
|
||||
* - readContentControlFields (FR-11) อ่านค่า content controls เป็น map tag→text
|
||||
* (ทุกชนิดบน host WordApi 1.5+ — ด้วย supportedContentControls ด้านล่าง)
|
||||
* · ไม่ได้ใช้ตอนสร้าง version แล้ว (2026-10-30 — server แกะจากไฟล์ .docx ด้วยตัว
|
||||
* เดียวกับ /api/template/extract) เหลือเป็น service API ที่มี test คุม semantics
|
||||
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\metadataService.ts
|
||||
*
|
||||
* กติกา: ทุก Word.run ต้องจบด้วย context.sync(); XML part ฝั่งเขียน **ห้าม throw**
|
||||
|
|
@ -254,7 +256,10 @@ export function supportedContentControls(context: Word.RequestContext): Word.Con
|
|||
}
|
||||
|
||||
/**
|
||||
* อ่าน content controls ทั้งเอกสารเป็น map `{ [tag]: text }` สำหรับแนบกับ upload version
|
||||
* อ่าน content controls ทั้งเอกสารเป็น map `{ [tag]: text }` — เดิมใช้แนบกับ upload
|
||||
* version (FR-11) แต่ตั้งแต่ 2026-10-30 server แกะจากไฟล์ .docx เองด้วยตัวเดียวกับ
|
||||
* /api/template/extract (เห็น CC ทุกชนิดเสมอไม่ขึ้นกับเวอร์ชัน WordApi) — เหลือไว้เป็น
|
||||
* service API ให้เรียกใช้/test ต่อ
|
||||
* - ใช้ supportedContentControls → host 1.5+ เห็น CC ทุกชนิด (รวม plain text ซึ่งเป็น
|
||||
* ส่วนใหญ่ของ template ศาล) ส่วน host ต่ำกว่า (Office 2019 = 1.3) เห็นเฉพาะ rich text
|
||||
* เท่าเดิม — ห้ามยก MinVersion ของ manifest เพื่อปลดข้อจำกัดนี้ (บทเรียน 2026-10-16)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@
|
|||
* - `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
|
||||
* → ดึงไฟล์ → POST version → คืนผลให้ UI แสดง/refresh history
|
||||
* (FR-11 fields เดิมอ่านฝั่ง client ตอน capture — 2026-10-30 ยกให้ server แกะจาก
|
||||
* ไฟล์ .docx ด้วยตัวเดียวกับ /api/template/extract แทน จึงไม่มีขั้นอ่าน fields แล้ว)
|
||||
*
|
||||
* หมายเหตุค่า id ใน metadata: `LastVersionId` และ `<Version id>` ใน XML part เก็บ
|
||||
* **clientVersionId** (GUID ที่ client generate) เพราะต้อง stamp ลงไฟล์ *ก่อน* upload —
|
||||
|
|
@ -21,7 +23,6 @@ import { DocumentCloseMode, closeDocument, saveDocumentVerified } from "./docume
|
|||
import { getCurrentDocumentFile, getCurrentDocumentPdf } from "./fileService";
|
||||
import {
|
||||
generateGuid,
|
||||
readContentControlFields,
|
||||
readVersionHistoryFromXml,
|
||||
stampTrackedProperties,
|
||||
writeVersionHistoryToXml,
|
||||
|
|
@ -82,7 +83,6 @@ function guessDocumentFileName(): string | undefined {
|
|||
async function uploadCurrentFile(
|
||||
documentId: string,
|
||||
clientVersionId: string,
|
||||
fields: Record<string, string>,
|
||||
message?: string,
|
||||
onProgress?: (progress: string) => void,
|
||||
templateId?: string
|
||||
|
|
@ -102,7 +102,7 @@ async function uploadCurrentFile(
|
|||
documentId,
|
||||
file,
|
||||
clientVersionId,
|
||||
fields,
|
||||
undefined,
|
||||
guessDocumentFileName(),
|
||||
message,
|
||||
pdf,
|
||||
|
|
@ -139,15 +139,14 @@ export async function startVersioning(
|
|||
{ id: clientVersionId, number: 1, timestamp, user: authService.getUsername() ?? "unknown" },
|
||||
]);
|
||||
|
||||
// 3) native save แล้ว capture ไฟล์ + fields
|
||||
// 3) native save แล้ว capture ไฟล์ (fields ของ version แกะจากไฟล์ฝั่ง server
|
||||
// ด้วยตัวเดียวกับ /api/template/extract — contract 2026-10-30 ไม่ต้องอ่านเองแล้ว)
|
||||
onProgress?.("กำลังบันทึกไฟล์…");
|
||||
await saveDocumentVerified();
|
||||
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
|
||||
const fields = await readContentControlFields();
|
||||
|
||||
// 4) upload version แรก (แนบ templateId เมื่อเป็นไฟล์ร่าง — FR-16)
|
||||
onProgress?.("กำลังอัปโหลด version 1…");
|
||||
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress, templateId);
|
||||
const result = await uploadCurrentFile(documentId, clientVersionId, message, onProgress, templateId);
|
||||
// FR-3 — สร้าง version สำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version"
|
||||
autoSaveService.clearUncommittedSinceVersion();
|
||||
return { documentId, ...result };
|
||||
|
|
@ -188,15 +187,13 @@ export async function saveAndVersion(
|
|||
entries.unshift({ id: clientVersionId, number: predictedNumber, timestamp, user });
|
||||
await writeVersionHistoryToXml(entries);
|
||||
|
||||
// 3) native save → capture ไฟล์ + fields ปัจจุบัน (FR-11)
|
||||
// 3) native save → capture ไฟล์ปัจจุบัน (fields แกะฝั่ง server จากไฟล์ที่อัปโหลด — 2026-10-30)
|
||||
onProgress?.("กำลังบันทึกไฟล์…");
|
||||
await saveDocumentVerified();
|
||||
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
|
||||
const fields = await readContentControlFields();
|
||||
|
||||
// 4) upload
|
||||
onProgress?.("กำลังอัปโหลด version ใหม่…");
|
||||
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress);
|
||||
const result = await uploadCurrentFile(documentId, clientVersionId, message, onProgress);
|
||||
|
||||
// 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort)
|
||||
if (result.versionNumber !== predictedNumber) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
* ไว้เพื่อ signature เดิมเท่านั้น ไม่ถูกส่งถึง server · templateId ก็เช่นกัน (มาจาก claims
|
||||
* ของ token — ส่งมาใน form = 400)
|
||||
* - **ห้ามส่ง userId/userName/templateId ใน FormData** (contract 2026-10-22 — ส่งมา = 400)
|
||||
* - **ห้ามส่ง fields ใน FormData เช่นกัน** (contract 2026-10-30) — server แกะค่า Content
|
||||
* Control จากไฟล์ .docx ที่อัปโหลดเองด้วยตัวเดียวกับ /api/template/extract)
|
||||
* - mock ยัง persist metadata ลง localStorage ต่อ documentId เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่
|
||||
* ในโหมด demo — blob ไฟล์จริงเฉพาะ session; localStorage เข้าไม่ได้ (webview บางตัว block)
|
||||
* = ใช้ memory อย่างเดียว ไม่ throw
|
||||
|
|
@ -18,13 +20,14 @@
|
|||
import * as authService from "./authService";
|
||||
import { generateGuid } from "./metadataService";
|
||||
import { ApiError, apiGetJson, apiSendExpectJson } from "./apiClient";
|
||||
import { UploadVersionResult, VersionRecord } from "../models/versionRecord";
|
||||
import { ExtractSnapshotFields, UploadVersionResult, VersionRecord } from "../models/versionRecord";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ของจริง — กลุ่ม docver (Bearer จาก ticket auth แนบอัตโนมัติโดย apiClient)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** DTO ของ GET /api/documents/versions (ใหม่ → เก่า) — PLAN-DOCVER.md §1 */
|
||||
/** DTO ของ GET /api/documents/versions (ใหม่ → เก่า) — PLAN-DOCVER.md §1
|
||||
* fields = ก้อนเดียวกับ return ของ /api/template/extract (server แกะจากไฟล์เอง) */
|
||||
interface DocumentVersionDTO {
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
|
|
@ -32,14 +35,16 @@ interface DocumentVersionDTO {
|
|||
createdByUserId: string;
|
||||
createdByUserName: string;
|
||||
contentHash: string;
|
||||
fields: Record<string, string> | null;
|
||||
fields: ExtractSnapshotFields | null;
|
||||
message: string | null;
|
||||
hasPdf: boolean;
|
||||
}
|
||||
|
||||
/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (multipart POST)
|
||||
* + FR-13: message คือ commit message ทางเลือก
|
||||
* + FR-16: templateId เก็บไว้ตาม signature เดิม — ไม่ส่งถึง server (มาจาก claims ของ token) */
|
||||
* + FR-16: templateId เก็บไว้ตาม signature เดิม — ไม่ส่งถึง server (มาจาก claims ของ token)
|
||||
* + fields ก็เก็บไว้ตาม signature เดิมเช่นกัน — ไม่ส่งถึง server (contract 2026-10-30:
|
||||
* server แกะจากไฟล์ .docx เองด้วยตัวเดียวกับ /api/template/extract ส่งมา = 400) */
|
||||
export async function uploadVersion(
|
||||
documentId: string,
|
||||
file: Blob,
|
||||
|
|
@ -52,6 +57,7 @@ export async function uploadVersion(
|
|||
): Promise<UploadVersionResult> {
|
||||
if (!authService.isDemoMode()) {
|
||||
void templateId; // contract 2026-10-22 — ห้ามส่ง (identity ทั้งหมดจาก Bearer token)
|
||||
void fields; // contract 2026-10-30 — ห้ามส่ง (server แกะจากไฟล์เอง ดูด้านบน)
|
||||
const form = new FormData();
|
||||
form.append("file", file, fileName ?? "document.docx");
|
||||
// pdf optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มีสำเนา PDF
|
||||
|
|
@ -61,9 +67,6 @@ export async function uploadVersion(
|
|||
if (clientVersionId) {
|
||||
form.append("clientVersionId", clientVersionId);
|
||||
}
|
||||
if (fields && Object.keys(fields).length > 0) {
|
||||
form.append("fields", JSON.stringify(fields));
|
||||
}
|
||||
const trimmedMessage = message?.trim();
|
||||
if (trimmedMessage) {
|
||||
form.append("message", trimmedMessage);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue