implement phase 4
This commit is contained in:
parent
25c2ce53cd
commit
f4b1c3e14b
27 changed files with 6929 additions and 48 deletions
231
addin/src/services/metadataService.ts
Normal file
231
addin/src/services/metadataService.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
/* 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** สร้าง 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;
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue