/* 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 `` */ export interface XmlVersionEntry { id: string; number: number; timestamp: string; user: string; } /** อ่าน custom properties ทั้ง 4 ตัวที่ระบบสนใจ */ export async function readTrackedProperties(): Promise { 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 { 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 { 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, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** สร้าง XML ของ `` ตามรูปใน spec ส่วน 8 */ export function buildVersionHistoryXml(entries: XmlVersionEntry[]): string { const elements = entries .map( (entry) => ` ` ) .join("\n"); return `\n${elements}\n`; } 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 { 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 { 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> { return Word.run(async (context) => { const controls = context.document.contentControls; controls.load("tag, text"); await context.sync(); const fields: Record = {}; 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; }); }