/* global Word, Office, 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 * (ทุกชนิดบน 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** * (known bug บน Mac บาง build — เขียนไม่ได้ก็ปล่อยผ่าน) */ import { saveDocumentVerified } from "./documentService"; /** 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"; /** FR-16 — stamp เฉพาะไฟล์ร่าง (draft) ที่เว็บสร้าง: template ต้นทาง (ค่าเดียวกับ AddInMetadata.TemplateIdPropertyName ฝั่ง backend) */ export const TEMPLATE_ID_PROPERTY = "TemplateId"; /** e-Court ticket — ไฟล์ที่ e-Court stamp คู่นี้มาใน custom properties คือ auth ทางเข้าเดียวของ add-in * (PLAN-ADDIN-AUTH.md §2: HMAC-SHA256(secret, ticket_data ดิบ) เป็น hex lowercase 64 ตัว) */ export const TICKET_DATA_PROPERTY = "ticket_data"; export const TICKET_HMAC_PROPERTY = "HmacSha256"; /** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */ export interface TrackedDocumentProperties { savedByAddIn?: string; documentTrackingId?: string; lastVersionId?: string; lastVersionTimestamp?: string; /** มีเฉพาะไฟล์ร่าง (FR-16) — ส่งต่อให้ backend ตอน Start Versioning เพื่อผูก Document กับ template */ templateId?: string; /** e-Court ticket — มีครบทั้งคู่ = ตรวจ HMAC แล้วเข้าใช้งานได้ (auth ทางเข้าเดียว) */ ticketData?: string; ticketHmac?: string; } /** item ใน Custom XML Part `` */ export interface XmlVersionEntry { id: string; number: number; timestamp: string; user: string; } /** อ่าน custom properties ทั้ง 7 ตัวที่ระบบสนใจ (4 ตัวของ tracked + TemplateId ของไฟล์ร่าง FR-16 * + ticket_data/HmacSha256 ที่ e-Court stamp มากับไฟล์) */ 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); const templateId = properties.getItemOrNullObject(TEMPLATE_ID_PROPERTY); const ticketData = properties.getItemOrNullObject(TICKET_DATA_PROPERTY); const ticketHmac = properties.getItemOrNullObject(TICKET_HMAC_PROPERTY); // load "value" ของ null-object proxy ก็ทำให้ isNullObject แม่นหลัง sync savedByAddIn.load("value"); documentTrackingId.load("value"); lastVersionId.load("value"); lastVersionTimestamp.load("value"); templateId.load("value"); ticketData.load("value"); ticketHmac.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), templateId: read(templateId), ticketData: read(ticketData), ticketHmac: read(ticketHmac), }; }); } /** FR-1 — ไฟล์นี้ผ่าน add-in นี้หรือเปล่า (รวมไฟล์ที่ generate จากเว็บ ที่ backend stamp ไว้แล้ว) * หมายเหตุ: TaskpaneApp.detect() ทำนายค่านี้ inline จาก readTrackedProperties() อยู่แล้ว (ต้องอ่าน properties * ครั้งเดียวทั้งชุดเพื่อให้ไฟล์ร่าง untracked ได้อ่าน TemplateId ด้วย — FR-16) ฟังก์ชันนี้จึงเหลือเป็น * service API ที่มี test คุม semantics ของ FR-1 ไว้ */ 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(); }); } // --------------------------------------------------------------------------- // Auto-open taskpane (Office.AutoShowTaskpaneWithDocument) — 2026-11-05 // --------------------------------------------------------------------------- /** ชื่อ setting ที่ทำให้ taskpane เด้งอัตโนมัติเมื่อเปิดเอกสารนี้ครั้งถัดไป — ต้องตรงกับ * `` ใน manifest.template.xml และ `` ที่ lib/lib-tmpl.ts stamp * ลง word/webextensions/ ฝั่ง server (learn.microsoft.com/.../automatically-open-a-task-pane-with-a-document) */ export const AUTO_OPEN_SETTING = "Office.AutoShowTaskpaneWithDocument"; /** memo กัน double-call — detect() ถูกเรียกซ้ำ (ปุ่ม "ลองใหม่") ก็แชร์ promise เดิม; * ล้มแล้วล้างทิ้งให้ครั้งถัดไปได้ลองใหม่ */ let autoOpenPromise: Promise | null = null; /** * tag เอกสารปัจจุบันให้ taskpane เด้งอัตโนมัติครั้งถัดไปที่เปิดไฟล์นี้ — **best-effort * ห้าม throw ทุกกรณี** (auto-open เป็น UX enhancement: ล้ม = ผู้ใช้กดปุ่มเองตามเดิม) * คืน true = setting มีอยู่แล้ว หรือเขียนสำเร็จ · false = ทำไม่สำเร็จ (เงียบ ไม่ใช่ error ของ UI) */ export function enableAutoOpenTaskpane(): Promise { if (autoOpenPromise === null) { autoOpenPromise = (async () => { try { const settings = Office.context.document.settings; if (settings.get(AUTO_OPEN_SETTING) === true) return true; // idempotent — ไม่เขียนซ้ำ settings.set(AUTO_OPEN_SETTING, true); const saved = await new Promise((resolve) => { settings.saveAsync((result) => resolve(result.status === Office.AsyncResultStatus.Succeeded)); }); if (!saved) { console.warn("auto-open: settings.saveAsync ไม่สำเร็จ — taskpane จะไม่เด้งครั้งถัดไป"); return false; } try { // saveAsync เขียนเฉพาะ document ในหน่วยความจำ — persist ลงดิสก์จริงต้องตามด้วย native // save (ไฟล์ read-only/Protected View = ยอมแพ้เงียบ ๆ: auto-save 30 วิเป็น net รอง) await saveDocumentVerified(); } catch (error) { console.warn("auto-open: บันทึกไฟล์ไม่สำเร็จ (read-only?) — setting อาจยังไม่ถึงดิสก์", error); } return true; } catch (error) { // นอก host Word (browser/SSR) หรือ host ไม่รองรับ Settings API — เงียบไป console.warn("auto-open: เขียน setting ไม่สำเร็จ (นอก Word หรือ host ไม่รองรับ)", error); return false; } })(); // ล้ม = ล้าง memo ให้ detect ครั้งถัดได้ลองใหม่ (ผ่านปุ่ม "ลองใหม่") void autoOpenPromise.then((ok) => { if (!ok) autoOpenPromise = null; }); } return autoOpenPromise; } /** 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) // --------------------------------------------------------------------------- /** host นี้มองเห็น CC ทุกชนิด (รวม plain text) ได้ไหม — WordApi 1.5+ เท่านั้น * (เรียกนอก Word เช่น browser ธรรมดา → catch คืน false เสมอ ตามแบบ canCloseDocument) * ย้ายมาจาก templateService (2026-10-28) ให้ readContentControlFields ใช้ร่วม — * collection `contentControls` ธรรมดามองเห็นเฉพาะ rich text CC ทำให้ fields ตอน * สร้าง version เหลือแค่ rich ตัวเดียว (เคสจริง T3: plain 26 + rich 1 = เห็นแค่ detail1) */ export function supportsAllContentControlTypes(): boolean { try { return Office.context.requirements.isSetSupported("WordApi", "1.5"); } catch { return false; } } /** collection ของ CC ที่ host รองรับสูงสุด — 1.5+ ใช้ getContentControls() (ทุกชนิด) * ต่ำกว่านั้นต้องยอมรับ collection rich-text-only */ export function supportedContentControls(context: Word.RequestContext): Word.ContentControlCollection { return supportsAllContentControlTypes() ? context.document.getContentControls() : context.document.contentControls; } /** * อ่าน 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) * - ไม่มี content controls / tag ว่าง → ข้าม (คืน {} ไม่ error) * - tag ซ้ำ → เอาตัวแรกตามลำดับในเอกสาร */ export async function readContentControlFields(): Promise> { return Word.run(async (context) => { const controls = supportedContentControls(context); 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; }); }