ไฟล์ร่าง (draft) — generate docx จาก template แบบ stateless ไม่สร้าง
Document/version; stamp เฉพาะ custom property TemplateId เพื่อให้ add-in
เห็นเป็น untracked; Start Versioning ส่ง form field templateId ให้ backend
ผูก template ตอน auto-create — เอกสารเริ่มที่ v1 (ไม่มี v0) + field sync เต็มระบบ
- Backend: POST /api/templates/{id}/generate-draft (200 คืนไฟล์) +
DocxGenerationService.GenerateDraft + แยก TemplateFieldValidation ใช้ร่วมกับ
generate + UploadVersion รับ templateId (non-GUID/unknown → 400,
deactivated → ผูกได้, document เดิม → เฉย) + CORS expose Content-Disposition
- Add-in: readTrackedProperties อ่าน TemplateId + ส่ง templateId เฉพาะ
startVersioning + hint ไฟล์ร่างใน task pane
- Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม + downloadFile รับ init (POST → blob)
- Docs: spec v8 (FR-16, AC-19) + Phase 12 + CONTEXT.md + ADR 0002 +
acceptance-review AC-19
- Tests: backend 82 / add-in 66 / web 59 + build ครบ; QA reviewer PASS
(2 rounds); ค้าง manual บน Word จริง
Co-Authored-By: Claude Code <noreply@anthropic.com>
241 lines
11 KiB
TypeScript
241 lines
11 KiB
TypeScript
/* 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";
|
|
/** FR-16 — stamp เฉพาะไฟล์ร่าง (draft) ที่เว็บสร้าง: template ต้นทาง (ค่าเดียวกับ AddInMetadata.TemplateIdPropertyName ฝั่ง backend) */
|
|
export const TEMPLATE_ID_PROPERTY = "TemplateId";
|
|
|
|
/** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */
|
|
export interface TrackedDocumentProperties {
|
|
savedByAddIn?: string;
|
|
documentTrackingId?: string;
|
|
lastVersionId?: string;
|
|
lastVersionTimestamp?: string;
|
|
/** มีเฉพาะไฟล์ร่าง (FR-16) — ส่งต่อให้ backend ตอน Start Versioning เพื่อผูก Document กับ template */
|
|
templateId?: string;
|
|
}
|
|
|
|
/** item ใน Custom XML Part `<VersionHistory>` */
|
|
export interface XmlVersionEntry {
|
|
id: string;
|
|
number: number;
|
|
timestamp: string;
|
|
user: string;
|
|
}
|
|
|
|
/** อ่าน custom properties ทั้ง 5 ตัวที่ระบบสนใจ (4 ตัวของ tracked + TemplateId ของไฟล์ร่าง FR-16) */
|
|
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);
|
|
const templateId = properties.getItemOrNullObject(TEMPLATE_ID_PROPERTY);
|
|
// load "value" ของ null-object proxy ก็ทำให้ isNullObject แม่นหลัง sync
|
|
savedByAddIn.load("value");
|
|
documentTrackingId.load("value");
|
|
lastVersionId.load("value");
|
|
lastVersionTimestamp.load("value");
|
|
templateId.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),
|
|
};
|
|
});
|
|
}
|
|
|
|
/** FR-1 — ไฟล์นี้ผ่าน add-in นี้หรือเปล่า (รวมไฟล์ที่ generate จากเว็บ ที่ backend stamp ไว้แล้ว)
|
|
* หมายเหตุ: App.detect() ทำนายค่านี้ inline จาก readTrackedProperties() อยู่แล้ว (ต้องอ่าน properties
|
|
* ครั้งเดียวทั้งชุดเพื่อให้ไฟล์ร่าง untracked ได้อ่าน TemplateId ด้วย — FR-16) ฟังก์ชันนี้จึงเหลือเป็น
|
|
* service API ที่มี test คุม semantics ของ FR-1 ไว้ */
|
|
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;
|
|
});
|
|
}
|