eCourt-note/scripts/fetch-office-js.mjs
Your Name c401f4deb7 feat(wordaddin): port Word add-in taskpane from poc
พอร์ต taskpane ของ Word add-in "e-Court Documents Versioning" จาก
D:\word-addin-poc มาเป็น sibling app ที่ route /docedit/wordaddin:

- wordaddin/ (client-only zone): services พอร์ตดิบทั้งชุด
  (authService/versioningService เป็น mock รอ backend จริง) + models + config
- components/wordaddin/: WordAddinGate ฉีด office.js self-host (singleton
  กัน StrictMode double-inject + fallback timer ครอบเคส onReady เงียบ)
  และ TaskpaneApp state machine แปลง JSX เป็น Genesis design system
- scripts/fetch-office-js.mjs (postinstall ตัวที่สาม): ดาวน์โหลด Office.js
  runtime ล่าสุดจาก CDN Microsoft ไป public/wordaddin/office-js/ (gitignore
  สร้างใหม่ได้ — npm @microsoft/office-js ตกรุ่นไม่มี Document.close)
- middleware/basePath/root page: เปิด /wordaddin เป็น public (auth แยกจาก
  แอป note), root /docedit redirect ไป taskpane ยกเว้นลิงก์ e-Court N8
  (?fileUrl=&fileId=&typeId=) ที่ยังส่งต่อ /note ตามเดิม
- docker/web.Dockerfile รัน fetch-office-js ตอน build + npm scripts
  dev:https สำหรับ sideload ตาม docs/WORDADDIN-DEV.md

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 12:07:25 +07:00

153 lines
9.5 KiB
JavaScript

// ดาวน์โหลด Office.js runtime "ฉบับล่าสุด" จาก CDN อย่างเป็นทางการ
// (appsforoffice.microsoft.com/lib/1/hosted) มา self-host ที่ public/wordaddin/office-js/
// ให้ taskpane ของ wordaddin โหลดแบบ same-origin ได้ (/docedit/wordaddin/office-js/office.js)
// — ระบบ deploy ใช้ใน intranet จึง self-host (CDN ใช้ไม่ได้ในเครือข่ายศาล) ส่วนตัว
// ดาวน์โหลดเองตอน npm/pnpm install บนเครื่องที่มีอินเทอร์เน็ต เหมือนดึง dependency อื่น ๆ
//
// ทำไมไม่ใช้แพ็กเกจ @microsoft/office-js แบบเดิม: npm หยุดอัปเดตที่ 1.1.110 (~2018)
// ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` ที่ FR-18 ต้องใช้ (เรียกแล้ว
// ได้ "context.document.close is not a function") และไม่มี enum Word.CloseBehavior /
// Word.SaveBehavior ทับ ๆ กับ @types/office-js ฉบับใหม่ — จึงต้องเป็นฉบับ CDN ล่าสุดที่ทัน
// API ปัจจุบัน (rolling — ได้ตัวใหม่ทุกครั้งที่ติดตั้ง และตรงกับ @types ที่ compile ด้วย)
//
// ชุดไฟล์ = **เฉพาะของ Word** ตามที่ loader อ้างถึง: ไฟล์ host ราย platform จากตาราง
// เวอร์ชันใน office.js (word: {ios:16.00, mac:16.00, web:16.00, win32:16.01}) + ไฟล์
// legacy 15.x กัน Word รุ่นเก่าในองค์กร + o15apptofilemappingtable/microsoftajax ที่
// loader ดึง + office_strings.js ต่อ locale — ไฟล์หลักหายตัวไหน (CDN เปลี่ยนชื่อ/ลบ) =
// exit 1 ดัง ๆ ทันที ไม่ปล่อยให้ taskpane พังเงียบ ๆ (pattern เดียวกับ copy-swagger-ui.mjs)
// ยกเว้น office_strings.js ของ locale รอง — หายเป็น warning (loader fallback เป็น en-us)
//
// หลังดาวน์โหลดตรวจ API surface ของ word-win32-16.01.js อีกชั้น: ต้องมี "Document.close"
// และ CloseBehavior — วันหนึ่งถ้า CDN ถอด API ที่ add-in ใช้ ให้ติดตั้งพังตั้งแต่ต้น
// ไม่ใช่มาพังตอนผู้ใช้กดปุ่มใน Word
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
const CDN = "https://appsforoffice.microsoft.com/lib/1/hosted";
const DEST = "public/wordaddin/office-js";
/** ไฟล์หลักที่ loader ดึงตอนรันจริง — หายตัวไหน = พังทั้ง taskpane */
const REQUIRED_FILES = [
"office.js",
"o15apptofilemappingtable.js",
"microsoftajax.js",
// host files ราย platform (ตารางเวอร์ชันของ loader) + legacy 15.x
"word-win32-16.01.js",
"word-web-16.00.js",
"word-mac-16.00.js",
"word-ios-16.00.js",
"word-15.js",
"word-15.01.js",
"word-15.02.js",
"wordios-15.js",
];
/** locale ที่หายไม่ได้ (ผู้ใช้หลักของระบบ) — locale อื่นหายเป็น warning */
const REQUIRED_LOCALES = ["en-us", "th-th"];
/** locale ที่ CDN มี office_strings.js จริง (snapshot 2026-10 — Microsoft รวม locale รอง
* ให้ fallback ไป en-us แล้ว เหลือ ~64 ภาษาหลัก) — locale อื่น ๆ ของผู้ใช้จะได้ข้อความ
* อังกฤษ ไม่ใช่ taskpane พัง */
const LOCALES = [
"am-et", "ar-sa", "bg-bg", "bn-in", "ca-es", "cs-cz", "cy-gb", "da-dk", "de-de", "el-gr",
"en-us", "es-es", "es-mx", "et-ee", "eu-es", "fa-ir", "fi-fi", "fil-ph", "fr-ca", "fr-fr",
"gl-es", "gu-in", "he-il", "hi-in", "hr-hr", "hu-hu", "id-id", "is-is", "it-it", "ja-jp",
"kk-kz", "kn-in", "ko-kr", "lo-la", "lt-lt", "lv-lv", "ml-in", "mr-in", "ms-my", "nb-no",
"nl-nl", "nn-no", "pl-pl", "pt-br", "pt-pt", "ro-ro", "ru-ru", "sk-sk", "sl-si", "sr-cyrl-cs",
"sr-cyrl-rs", "sr-latn-cs", "sr-latn-rs", "sv-se", "sw-ke", "ta-in", "te-in", "th-th", "tr-tr",
"uk-ua", "ur-pk", "vi-vn", "zh-cn", "zh-tw",
];
/** ดึงไฟล์จาก CDN แบบ retry — คืน null เมื่อล้มทุกรอบ (เรียกต่อไปตัดสิน fatal/warning เอง) */
async function fetchText(path, retries = 2) {
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const response = await fetch(`${CDN}/${path}`, { redirect: "follow" });
if (response.ok) {
return { text: await response.text(), lastModified: response.headers.get("last-modified") };
}
if (response.status === 404) return null; // ไม่มีจริง — retry ไม่ช่วย
throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (attempt === retries) {
console.error(`[fetch-office-js] ดึง ${path} ไม่สำเร็จหลัง retry: ${error?.message ?? error}`);
return null;
}
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
}
}
return null;
}
function save(path, text) {
const target = join(DEST, path);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, text);
}
// ลบโฟลเดอร์เดิมเป็น best-effort — บน Windows ตอน next dev เปิดค้าง chokidar อาจจับ
// handle โฟลเดอร์ public/ ไว้ (EPERM แม่น ๆ) ลบไม่ได้ก็เขียนทับในที่เดิมได้ (ไฟล์ที่ CDN
// เลิกใช้จะตกค้างเงียบ ๆ ใน dev แต่ fresh install/Docker ได้โฟลเดอร์สะอาดตามปกติ)
try {
rmSync(DEST, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 });
} catch {
console.warn("[fetch-office-js] ลบโฟลเดอร์เดิมไม่ได้ (process อื่นจับ handle อยู่ เช่น dev server) — เขียนทับในที่เดิม");
}
mkdirSync(DEST, { recursive: true });
// --- ไฟล์หลัก: ทีละตัว (ไฟล์ใหญ่) ตัวไหนหาย = fatal ---
let officeLastModified;
for (const file of REQUIRED_FILES) {
const result = await fetchText(file);
if (!result) {
console.error(
`[fetch-office-js] ไม่ได้ไฟล์หลัก ${file} — CDN เปลี่ยนชื่อ/ลบไฟล์รึเปล่า ` +
`(ปรับ REQUIRED_FILES ใน scripts/fetch-office-js.mjs) หรือเครือข่ายสู่ appsforoffice.microsoft.com ไม่ผ่าน`,
);
process.exit(1);
}
save(file, result.text);
if (file === "office.js") officeLastModified = result.lastModified;
}
// --- locale strings: ยิงกลุ่มละ 8 ไฟล์ (ไฟล์เล็ก) — รองหาย = warning ---
const missingLocales = [];
for (let i = 0; i < LOCALES.length; i += 8) {
const batch = LOCALES.slice(i, i + 8);
await Promise.all(
batch.map(async (locale) => {
const result = await fetchText(`${locale}/office_strings.js`);
if (result) {
save(`${locale}/office_strings.js`, result.text);
} else {
missingLocales.push(locale);
}
}),
);
}
if (missingLocales.length > 0) {
console.warn(`[fetch-office-js] office_strings.js หาย ${missingLocales.length} locale (loader จะ fallback en-us): ${missingLocales.join(", ")}`);
}
const missingRequiredLocales = REQUIRED_LOCALES.filter((locale) => missingLocales.includes(locale));
if (missingRequiredLocales.length > 0) {
console.error(`[fetch-office-js] หาย office_strings.js ของ locale หลัก: ${missingRequiredLocales.join(", ")}`);
process.exit(1);
}
// --- ตรวจ API surface ที่ add-in ใช้จริง (กัน CDN regression หลุดเงียบ) ---
const win32 = readFileSync(join(DEST, "word-win32-16.01.js"), "utf8");
const missingApis = [];
if (!win32.includes('"Document.close"')) missingApis.push('"Document.close" (FR-18 ปิดเอกสาร)');
if (!win32.includes("CloseBehavior")) missingApis.push("CloseBehavior enum");
if (missingApis.length > 0) {
console.error(
`[fetch-office-js] word-win32-16.01.js จาก CDN ไม่มี API: ${missingApis.join(", ")} — ` +
`อย่า deploy ตัวนี้ (ปุ่มปิดเอกสารจะพังใน Word) แจ้งทีมแล้วตรวจ scripts/fetch-office-js.mjs`,
);
process.exit(1);
}
console.log(
`[fetch-office-js] ดาวน์โหลด Office.js runtime ล่าสุดเรียบร้อย ` +
`(office.js ของวันที่ ${officeLastModified ?? "ไม่ทราบ"} — ${REQUIRED_FILES.length} ไฟล์หลัก + ${LOCALES.length - missingLocales.length} locale)`,
);