eCourt-Doc-Edit/addin/src/services/metadataService.ts

232 lines
9.9 KiB
TypeScript
Raw Normal View History

2026-08-19 21:35:07 +07:00
/* global Word, console */
/**
* Metadata ในไฟล์ .docx ที่ add-in อ่าน/เขียน (spec ส่วน 8):
* - Custom Document Properties = primary flag ตรวจจับ tracked document
* - Custom XML Part (ns `urn:myaddin:versioning`) = secondary เท่านั้น — backend คือ source of truth
* - readContentControlFields (FR-11) อ่านค่า content controls เป็น map tag→text
*
* กติกา: ทุก Word.run ต้องจบด้วย context.sync(); XML part ฝั่งเขียน **ห้าม throw**
* (known bug บน Mac บาง build — เขียนไม่ได้ก็ปล่อยผ่าน)
*/
/** GUID คงที่ของ add-in — ต้องตรงเป๊ะกับ `AddInMetadata.AddInSignatureGuid` ฝั่ง backend (spec ส่วน 8) */
export const ADDIN_SIGNATURE_GUID = "9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D";
export const CUSTOM_XML_NAMESPACE = "urn:myaddin:versioning";
export const SAVED_BY_ADD_IN_PROPERTY = "SavedByAddIn";
export const DOCUMENT_TRACKING_ID_PROPERTY = "DocumentTrackingId";
export const LAST_VERSION_ID_PROPERTY = "LastVersionId";
export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp";
/** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */
export interface TrackedDocumentProperties {
savedByAddIn?: string;
documentTrackingId?: string;
lastVersionId?: string;
lastVersionTimestamp?: string;
}
/** item ใน Custom XML Part `<VersionHistory>` */
export interface XmlVersionEntry {
id: string;
number: number;
timestamp: string;
user: string;
}
/** อ่าน custom properties ทั้ง 4 ตัวที่ระบบสนใจ */
export async function readTrackedProperties(): Promise<TrackedDocumentProperties> {
return Word.run(async (context) => {
const properties = context.document.properties.customProperties;
const savedByAddIn = properties.getItemOrNullObject(SAVED_BY_ADD_IN_PROPERTY);
const documentTrackingId = properties.getItemOrNullObject(DOCUMENT_TRACKING_ID_PROPERTY);
const lastVersionId = properties.getItemOrNullObject(LAST_VERSION_ID_PROPERTY);
const lastVersionTimestamp = properties.getItemOrNullObject(LAST_VERSION_TIMESTAMP_PROPERTY);
// load "value" ของ null-object proxy ก็ทำให้ isNullObject แม่นหลัง sync
savedByAddIn.load("value");
documentTrackingId.load("value");
lastVersionId.load("value");
lastVersionTimestamp.load("value");
await context.sync();
const read = (property: Word.CustomProperty): string | undefined =>
property.isNullObject || property.value === undefined || property.value === null
? undefined
: String(property.value);
return {
savedByAddIn: read(savedByAddIn),
documentTrackingId: read(documentTrackingId),
lastVersionId: read(lastVersionId),
lastVersionTimestamp: read(lastVersionTimestamp),
};
});
}
/** FR-1 — ไฟล์นี้ผ่าน add-in นี้หรือเปล่า (รวมไฟล์ที่ generate จากเว็บ ที่ backend stamp ไว้แล้ว) */
export async function isTrackedDocument(): Promise<boolean> {
const properties = await readTrackedProperties();
return properties.savedByAddIn === ADDIN_SIGNATURE_GUID;
}
export interface StampPropertiesInput {
/** tracking id ของเอกสาร — ส่งเฉพาะตอนสร้างใหม่ (Start Versioning) หรือตอน stamp รอบแรก */
documentTrackingId?: string;
lastVersionId?: string;
lastVersionTimestamp?: string;
}
/**
* เขียน/stamp custom properties — ใช้ add() ทับของเดิม (add กับ key ที่มีอยู่ = update ค่า
* ต่างจาก XML part ที่ต้อง delete ก่อน) พร้อมกันทั้งชุดใน Word.run เดียว
*/
export async function stampTrackedProperties(input: StampPropertiesInput): Promise<void> {
await Word.run(async (context) => {
const properties = context.document.properties.customProperties;
properties.add(SAVED_BY_ADD_IN_PROPERTY, ADDIN_SIGNATURE_GUID);
if (input.documentTrackingId !== undefined) {
properties.add(DOCUMENT_TRACKING_ID_PROPERTY, input.documentTrackingId);
}
if (input.lastVersionId !== undefined) {
properties.add(LAST_VERSION_ID_PROPERTY, input.lastVersionId);
}
if (input.lastVersionTimestamp !== undefined) {
properties.add(LAST_VERSION_TIMESTAMP_PROPERTY, input.lastVersionTimestamp);
}
await context.sync();
});
}
/** GUID ใหม่สำหรับ tracking id / clientVersionId */
export function generateGuid(): string {
if (typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
// ---------------------------------------------------------------------------
// Custom XML Part — secondary history (ไม่ใช่ source of truth)
// ---------------------------------------------------------------------------
function escapeXmlAttribute(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/** สร้าง XML ของ `<VersionHistory>` ตามรูปใน spec ส่วน 8 */
export function buildVersionHistoryXml(entries: XmlVersionEntry[]): string {
const elements = entries
.map(
(entry) =>
` <Version id="${escapeXmlAttribute(entry.id)}" number="${entry.number}" timestamp="${escapeXmlAttribute(
entry.timestamp
)}" user="${escapeXmlAttribute(entry.user)}"/>`
)
.join("\n");
return `<VersionHistory xmlns="${CUSTOM_XML_NAMESPACE}">\n${elements}\n</VersionHistory>`;
}
function parseVersionHistoryXml(xml: string): XmlVersionEntry[] {
const doc = new DOMParser().parseFromString(xml, "application/xml");
const nodes = Array.from(doc.getElementsByTagNameNS(CUSTOM_XML_NAMESPACE, "Version"));
return nodes
.map((node) => ({
id: node.getAttribute("id") ?? "",
number: Number(node.getAttribute("number") ?? Number.NaN),
timestamp: node.getAttribute("timestamp") ?? "",
user: node.getAttribute("user") ?? "",
}))
.filter((entry) => entry.id !== "" && Number.isFinite(entry.number));
}
/** อ่าน compact history จาก XML part — fail อ่านเป็น [] เสมอ (ไม่ throw) */
export async function readVersionHistoryFromXml(): Promise<XmlVersionEntry[]> {
try {
return await Word.run(async (context) => {
const scoped = context.document.customXmlParts.getByNamespace(CUSTOM_XML_NAMESPACE);
context.load(scoped);
await context.sync();
const xmlResults = scoped.items.map((part) => part.getXml());
await context.sync();
const entries: XmlVersionEntry[] = [];
for (const result of xmlResults) {
entries.push(...parseVersionHistoryXml(result.value));
}
entries.sort((a, b) => b.number - a.number); // ใหม่ → เก่า
return entries;
});
} catch (error) {
console.warn("readVersionHistoryFromXml failed (secondary storage — ปล่อยผ่าน):", error);
return [];
}
}
/**
* เขียน XML part ด้วย pattern delete-then-add (แก้ part เดิมตรงๆ ไม่ได้)
* ⚠️ ห้าม throw แม้จะล้มเหลว — known bug บน Word for Mac บาง build
* คืน true = เขียนสำเร็จ, false = fail แบบ silent (backend ยังเป็น source of truth อยู่ดี — AC-10)
*/
export async function writeVersionHistoryToXml(entries: XmlVersionEntry[]): Promise<boolean> {
try {
await Word.run(async (context) => {
// 1) ลบ part เดิมใน namespace นี้ทั้งหมดก่อน
const scoped = context.document.customXmlParts.getByNamespace(CUSTOM_XML_NAMESPACE);
context.load(scoped);
await context.sync();
for (const part of scoped.items) {
part.delete();
}
if (scoped.items.length > 0) {
await context.sync();
}
// 2) add ของใหม่
context.document.customXmlParts.add(buildVersionHistoryXml(entries));
await context.sync();
});
return true;
} catch (error) {
console.warn("writeVersionHistoryToXml failed (silent per spec — known Mac bug):", error);
return false;
}
}
// ---------------------------------------------------------------------------
// Content controls → fields (FR-11)
// ---------------------------------------------------------------------------
/**
* อ่าน content controls ทั้งเอกสารเป็น map `{ [tag]: text }` สำหรับแนบกับ upload version
* - ไม่มี content controls / tag ว่าง → ข้าม (คืน {} ไม่ error)
* - tag ซ้ำ → เอาตัวแรกตามลำดับในเอกสาร
*/
export async function readContentControlFields(): Promise<Record<string, string>> {
return Word.run(async (context) => {
const controls = context.document.contentControls;
controls.load("tag, text");
await context.sync();
const fields: Record<string, string> = {};
for (const control of controls.items) {
const tag = (control.tag ?? "").trim();
if (tag === "") {
continue;
}
if (!Object.prototype.hasOwnProperty.call(fields, tag)) {
fields[tag] = control.text;
}
}
return fields;
});
}