eCourt-note/wordaddin/services/templateService.ts
Your Name 6daee15851
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m12s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 14s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
feat(wordaddin): add template tools tab
แท็บที่สองของ taskpane "เครื่องมือ Template" (TemplateToolsPanel +
templateService — Office.js ล้วน ไม่ยิง backend ใช้ได้ทั้ง phase
untracked/tracking) สำหรับคนออกแบบ template:

- สแกน Content Controls ทั้งเอกสาร + เตือน tag ว่าง/ซ้ำ + ปุ่มดูช่อง
  (select เลื่อน Word ไปให้เอง)
- กรอกค่าทดสอบเขียนกลับเอกสารจริงเพื่อดูผลจัดหน้า — ช่องว่างไม่ถูกแตะ,
  เขียนแยก Word.run ราย tag ให้ fail เฉพาะตัวพร้อมสาเหตุจาก debugInfo
- แทรกย่อหน้า/ตัวแบ่งบรรทัด/ตัวแบ่งหน้าที่ cursor — page break มี
  fallback OOXML block-level กัน E_FAIL ในบางบริบท (เช่น cursor ในตาราง)
- มองเห็น CC ทุกชนิดผ่าน feature-detect WordApi 1.5
  (Document.getContentControls) — Office 2019 เห็นเฉพาะ rich text พร้อม
  warning แยก state ไม่โดน banner อื่นล้าง, ห้ามยก MinVersion ของ manifest

จาก code review 2026-10-20:

- ปุ่ม toggle แท็บปิดระหว่าง flow ที่เขียนเอกสารครบทุกตัว
  (saving/starting/saveBusy/templateBusy — panel รายงาน busy ผ่าน
  onBusyChange พร้อม cleanup ตอน unmount) กัน flow versioning มา
  stamp/capture ซ้อน operation ของแท็บ template ที่ยังรันอยู่
- banner auto-save error ยกจาก SaveControls มาไว้เหนือ toggle
  ให้เห็นทั้งสองแท็บ
- สแกน merge หลังเขียนค่าตัด tag ที่หายไปจากเอกสารทิ้ง (ไม่ค้าง
  ไปโดน missingTags รอบถัดไป)
- รวม describeWordError เป็น getDetailedErrorMessage ที่ errors.ts

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 09:49:17 +07:00

240 lines
15 KiB
TypeScript

/* global Word, Office */
/**
* แท็บ "เครื่องมือ Template" (แท็บที่สองของ taskpane) — เครื่องมือจัดการ Content Controls
* ฝั่ง Office.js ล้วน ไม่ยิง backend: สแกนรายช่อง / กระโดดไปเลือกช่อง / เขียนค่าทดสอบกลับ
* เข้าเอกสาร / แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้าที่ cursor
*
* API ที่ใช้เป็น WordApi 1.1 เกือบทั้งหมด (ยืนยันจาก @types/office-js 1.0.612) ยกเว้น
* getByIdOrNullObject (1.3) — ไม่เกินความต้องการต่ำสุดของ manifest (1.3) จึงใช้ได้ทุก platform
* รวม Office 2019 และ Word on the web
*
* ⚠️ collection `contentControls` ธรรมดามองเห็น**เฉพาะ rich text CC** (docstring ของ
* Body.contentControls: "rich text content control objects") — template ของศาลส่วนใหญ่เป็น
* plain text CC จึงต้องใช้ `Document.getContentControls()` (WordApi 1.5, ครอบทุกชนิดที่รองรับ)
* ผ่าน feature-detect ตอน runtime แทนการยก MinVersion ของ manifest (Office 2019 = 1.3
* ต้องยังโหลดได้ — เห็นเฉพาะ rich text + UI แจ้งเตือน)
*
* กติกาตาม repo: ห้ามมี Office/Word access ระดับ top-level (module นี้ถูก evaluate ใน
* browser ธรรมดาตอนโหมด `?addindemo` ที่ไม่มี Office global — top-level access จะ throw ก่อน UI ทัน render) · operation ที่เขียนเอกสารต้อง suspend/resume
* auto-save คู่กันเสมอ และเรียก notifyDocumentChanged() หลังสำเร็จให้ debounce และ flag FR-3
* ทำงานถูกต้อง (pattern เดียวกับ versionFlow.ts)
*/
import { autoSaveService } from "./autoSaveService";
import { getDetailedErrorMessage } from "./errors";
/** ผลสแกนหนึ่งช่อง — id ของ CC ไม่เสถียรข้าม session/coauthor (ตาม docstring ของ Office.js
* เอง: "The ID differs across sessions and coauthors") จึงใช้ได้เฉพาะอายุผลสแกนรอบเดียว ห้ามเก็บไปใช้ภายหลัง */
export interface ScannedField {
id: number;
/** tag ดิบไม่ trim — เพื่อให้ UI เตือน "tag ว่าง / มี space นำ-ท้าย" ได้ (ตอนเทียบ/ใช้งานคือ trim เสมอ) */
tag: string;
title: string;
text: string;
/** enum ดิบ เช่น "RichTextInline" — ไว้ debug ใน tooltip */
type: string;
/** heuristics หยาบจาก enum type: ลงท้ายด้วย "Inline" = ในบรรทัด ไม่งั้น = บล็อก */
kind: "inline" | "block";
}
function toScannedField(control: Word.ContentControl): ScannedField {
const type = control.type ?? "";
return {
id: control.id,
tag: control.tag ?? "",
title: control.title ?? "",
text: control.text ?? "",
type,
kind: type.endsWith("Inline") ? "inline" : "block",
};
}
/** host นี้มองเห็น CC ทุกชนิด (รวม plain text) ได้ไหม — WordApi 1.5+ เท่านั้น
* (เรียกนอก Word เช่น browser ธรรมดา → catch คืน false เสมอ ตามแบบ canCloseDocument) */
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 */
function supportedContentControls(context: Word.RequestContext): Word.ContentControlCollection {
return supportsAllContentControlTypes()
? context.document.getContentControls()
: context.document.contentControls;
}
/** สแกน Content Controls ทั้งเอกสาร (1 sync) — เรียงตามลำดับในเอกสาร · ไม่มีช่อง = คืน [] ไม่ error
* อ่านอย่างเดียวจึงไม่ต้อง suspend auto-save */
export async function listContentControls(): Promise<ScannedField[]> {
return Word.run(async (context) => {
const controls = supportedContentControls(context);
controls.load("id, tag, title, text, type");
await context.sync();
return controls.items.map(toScannedField);
});
}
/** เลือก + เลื่อน Word ไปที่ช่องตาม id ของผลสแกน — select() ตาม docstring จะ scroll ให้เอง
* ไม่ใช่การแก้เอกสาร (document.saved ไม่ dirty) จึงไม่ต้อง suspend */
export async function selectContentControl(id: number): Promise<void> {
await Word.run(async (context) => {
const control = supportedContentControls(context).getByIdOrNullObject(id);
control.load("id");
await context.sync();
if (control.isNullObject) {
throw new Error("ไม่พบช่องนี้ในเอกสาร (อาจถูกลบไปแล้ว) — กดสแกนใหม่");
}
control.select();
await context.sync();
});
}
/** แทรกย่อหน้าว่างใหม่หลังตำแหน่ง cursor (เหมือนกด Enter) — ปุ่มเสริมตอนจัดโครงสร้าง template
* (คำขอผู้ใช้ 2026-10-19: ตอนแก้ template ต้องเพิ่มย่อหน้าบ่อย แล้วเปิด formatting marks ตรวจ —
* ส่วนการเปิด/ปิด formatting marks เป็น application setting ที่ Office.js ไม่มี API ให้แตะ) */
export async function insertParagraphAtSelection(): Promise<void> {
await autoSaveService.suspend();
try {
await Word.run(async (context) => {
context.document.getSelection().insertParagraph("", "After");
await context.sync();
});
autoSaveService.notifyDocumentChanged();
} finally {
autoSaveService.resume();
}
}
/** page break แบบ paragraph สมบูรณ์ (block-level) — w:p ครอบ run ที่มี <w:br w:type="page"/>
* ใช้เป็นวิธีสำรองเมื่อ insertBreak("Page") โดน E_FAIL (0x80004005) ในบางบริบท เช่น cursor
* อยู่ในตาราง (เคสจริงบน T3.docx 2026-10-19 — template ของศาลมีตารางเป็นโครงหลัก) · ระดับ fragment
* ต้องเป็น block-level เต็มตัว ไม่ใช่ run เดี่ยว (ลองแล้วยัง E_FAIL อีกรอบ) */
const PAGE_BREAK_PARAGRAPH_OOXML =
'<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:r><w:br w:type="page"/></w:r></w:p>';
/** แทรกตัวแบ่งที่ตำแหน่ง cursor — "line" = ขึ้นบรรทัดใหม่ในย่อหน้าเดิม (Shift+Enter) สำหรับ
* จัดที่อยู่/รายการให้ตรงแนว, "page" = ขึ้นหน้าใหม่ (Ctrl+Enter) สำหรับเอกสารหลายหน้า (คำขอผู้ใช้ 2026-10-19) */
export async function insertBreakAtSelection(breakKind: "line" | "page"): Promise<void> {
await autoSaveService.suspend();
try {
if (breakKind === "line") {
await Word.run(async (context) => {
context.document.getSelection().insertBreak("Line", "After");
await context.sync();
});
} else {
// วิธีมาตรฐานก่อน — ถ้า host ปฏิเสธ (E_FAIL ในบางบริบท) สลับไป OOXML block-level
// (sync ที่พัง = คำสั่งถูกปฏิเสธ ไม่มีอะไรถูกเขียนค้าง จึง retry ได้; ถ้า retry ก็พัง
// โยน error ที่รวมทั้งสองสาเหตุ ไม่กลืนต้นเหตุแรกหาย)
let firstError: unknown;
try {
await Word.run(async (context) => {
context.document.getSelection().insertBreak("Page", "After");
await context.sync();
});
autoSaveService.notifyDocumentChanged();
return;
} catch (error) {
firstError = error;
}
try {
await Word.run(async (context) => {
context.document.getSelection().insertOoxml(PAGE_BREAK_PARAGRAPH_OOXML, "End");
await context.sync();
});
} catch (fallbackError) {
throw new Error(
`แทรกตัวแบ่งหน้าไม่สำเร็จทั้งสองวิธี — insertBreak: ${getDetailedErrorMessage(firstError)} · OOXML: ${getDetailedErrorMessage(fallbackError)}`,
);
}
}
autoSaveService.notifyDocumentChanged();
} finally {
autoSaveService.resume();
}
}
export interface ApplyFailure {
tag: string;
/** สาเหตุที่อ่านได้ (รวม code/errorLocation จาก OfficeExtension.Error ถ้ามี) */
reason: string;
}
export interface ApplyTestValuesResult {
/** จำนวนช่อง (CC) ที่ถูกเขียนค่าจริง — tag เดียวซ้ำหลายช่องนับทุกช่อง
* ⚠️ ข้อจำกัด granularity ราย tag: ถ้า tag หนึ่งผูกหลายช่องและ batch sync พังกลางคัน
* (ช่องแรกถูกเขียนแล้วแต่ช่องหลังโดนปฏิเสธ) ทั้ง tag จะถูกนับเป็น failures แม้บางช่องเขียนสำเร็จ —
* เรียก listContentControls หลังเขียนเพื่อเห็นค่าจริงในเอกสาร */
appliedCount: number;
/** tag ที่ส่งค่ามาแต่ไม่มีช่องตรงในเอกสารตอนเขียน (เช่น ถูกลบไปหลังสแกน) */
missingTags: string[];
/** tag ที่ Word ปฏิเสธการเขียน (เช่น เอกสารถูก Restrict Editing หรือช่องถูกล็อค/แก้ไม่ได้) —
* เขียนแบบแยกราย tag เพื่อให้เหลืออื่น ๆ ยังสำเร็จและรู้สาเหตุเป็นรายตัว */
failures: ApplyFailure[];
}
/** เขียนค่าทดสอบกลับเข้าทุก CC ที่ tag ตรง (tag ซ้ำหลายช่อง = เขียนทุกช่อง แบบตัวแปรเดียวหลายจุด)
* - ค่าที่เว้นว่างจะไม่ถูกแตะ (กันเผลอล้างค่าจริงของช่องนั้น) — ว่างทั้งหมด = ไม่เปิด Word.run
* - อ่าน id+tag สดรอบเดียว ไม่เชื่อ snapshot สแกน แล้วเขียน**แยก Word.run ราย tag** —
* tag ที่ Word ปฏิเสธ (เอกสารถูก Restrict Editing / ช่องแก้ไม่ได้) fail เฉพาะตัว เหลืออื่นยังเขียนได้
* และเหตุผลถูกเก็บลง failures (กรณีเคยเจอจริง: error 0x800A16C6 บนเอกสารที่ make-protected ล็อค) */
export async function applyTestValues(values: Record<string, string>): Promise<ApplyTestValuesResult> {
const entries = Object.entries(values).filter(([, value]) => value.trim() !== "");
if (entries.length === 0) {
return { appliedCount: 0, missingTags: [], failures: [] };
}
await autoSaveService.suspend();
try {
// สำรวจ id ของ CC ราย tag รอบเดียว (เทียบหลัง trim เหมือนฟอร์ม) — id ใช้ได้ทั้ง session นี้
const idsByTag = new Map<string, number[]>();
await Word.run(async (context) => {
const controls = supportedContentControls(context);
controls.load("id, tag");
await context.sync();
for (const control of controls.items) {
const tag = (control.tag ?? "").trim();
if (tag === "" || !(tag in values)) continue;
const ids = idsByTag.get(tag) ?? [];
ids.push(control.id);
idsByTag.set(tag, ids);
}
});
let appliedCount = 0;
const missingTags: string[] = [];
const failures: ApplyFailure[] = [];
for (const [tag, value] of entries) {
const ids = idsByTag.get(tag);
if (!ids || ids.length === 0) {
missingTags.push(tag);
continue;
}
try {
let written = 0;
await Word.run(async (context) => {
const targets = ids.map((id) => supportedContentControls(context).getByIdOrNullObject(id));
targets.forEach((control) => control.load("id"));
await context.sync();
const alive = targets.filter((control) => !control.isNullObject);
alive.forEach((control) => control.insertText(value, "Replace"));
await context.sync();
written = alive.length;
});
appliedCount += written;
} catch (tagError) {
failures.push({ tag, reason: getDetailedErrorMessage(tagError) });
}
}
if (appliedCount > 0) {
autoSaveService.notifyDocumentChanged();
}
return { appliedCount, missingTags, failures };
} finally {
autoSaveService.resume();
}
}