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>
This commit is contained in:
parent
7a146d48be
commit
c401f4deb7
37 changed files with 11216 additions and 17 deletions
154
wordaddin/services/versioningService.ts
Normal file
154
wordaddin/services/versioningService.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* MOCK — version API ฝั่ง client (รอ backend จริงตาม contract ใน docs/addin-ui-flow.md §7)
|
||||
* ต้นฉบับ: D:\word-addin-poc\addin\src\services\versioningService.ts (multipart POST ผ่าน apiClient)
|
||||
*
|
||||
* - คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId ไว้เป๊ะ —
|
||||
* วันปลด mock: ก้อบ body ของต้นฉบับกลับมาได้เลย
|
||||
* - ต้นฉบับมีอีก 2 ตัวที่ไม่มี caller ใน UI จึงไม่พอร์ต: buildDownloadUrl, downloadVersion
|
||||
* - **metadata ของ version ถูก persist ลง localStorage** (record เล็ก ๆ ต่อ documentId) —
|
||||
* ไม่งั้นปิดเอกสาร (taskpane ปิดตาม) แล้วเปิดใหม่ history ว่างเปล่า ทั้งที่ metadata
|
||||
* การ track อยู่ในไฟล์จริง ทำให้ทดสอบวง FR-18 ปิด→เปิดใหม่ไม่ได้; blob ของไฟล์จริง
|
||||
* ยังเก็บเฉพาะ session (mock download คืน about:blank อยู่แล้ว) — localStorage เข้า
|
||||
* ไม่ได้ (webview บางตัว block) = ใช้ memory อย่างเดียวแบบเดิม ไม่ throw
|
||||
*/
|
||||
|
||||
import * as authService from "./authService";
|
||||
import { generateGuid } from "./metadataService";
|
||||
import { UploadVersionResult, VersionRecord } from "../models/versionRecord";
|
||||
|
||||
interface StoredVersion {
|
||||
record: VersionRecord;
|
||||
docxBlob: Blob | null;
|
||||
pdfBlob: Blob | null;
|
||||
}
|
||||
|
||||
/** key = documentId, เก็บเรียงเก่า → ใหม่ */
|
||||
const store = new Map<string, StoredVersion[]>();
|
||||
|
||||
const STORAGE_KEY = "wordaddin-mock-versions";
|
||||
|
||||
/** โหลด record ที่ persist ไว้ (เฉพาะ metadata — blob ไม่ข้าม session) — พังใด ๆ = เริ่มว่าง */
|
||||
function loadPersisted(): Record<string, VersionRecord[]> {
|
||||
try {
|
||||
if (typeof window === "undefined") return {}; // SSR ของ client component — ไม่มี localStorage
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
const parsed = raw ? (JSON.parse(raw) as Record<string, VersionRecord[]>) : {};
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const persisted = loadPersisted();
|
||||
for (const [documentId, records] of Object.entries(persisted)) {
|
||||
if (Array.isArray(records)) {
|
||||
store.set(documentId, records.map((record) => ({ record, docxBlob: null, pdfBlob: null })));
|
||||
}
|
||||
}
|
||||
|
||||
/** บันทึกเฉพาะ metadata กลับลง localStorage — พัง = เงียบ (ยังมี memory ของ session นี้อยู่) */
|
||||
function persist(): void {
|
||||
try {
|
||||
const snapshot: Record<string, VersionRecord[]> = {};
|
||||
store.forEach((versions, documentId) => {
|
||||
snapshot[documentId] = versions.map((item) => item.record);
|
||||
});
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
// ignore — quota/private mode
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** SHA-256 จริงของไฟล์ที่ได้จาก Word — พังก็ไม่ throw (ใช้ค่าหยง ๆ ให้ UI ยังไปต่อได้) */
|
||||
async function mockContentHash(file: Blob): Promise<string> {
|
||||
try {
|
||||
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
} catch {
|
||||
return "mock-hash";
|
||||
}
|
||||
}
|
||||
|
||||
/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (mock: เก็บลง memory)
|
||||
* + FR-13: message คือ commit message ทางเลือก
|
||||
* + FR-16: templateId เฉพาะไฟล์ร่าง (mock ไม่ใช้ — คงไว้ตาม signature ต้นฉบับ) */
|
||||
export async function uploadVersion(
|
||||
documentId: string,
|
||||
file: Blob,
|
||||
clientVersionId: string,
|
||||
fields?: Record<string, string>,
|
||||
fileName?: string,
|
||||
message?: string,
|
||||
pdf?: Blob,
|
||||
templateId?: string
|
||||
): Promise<UploadVersionResult> {
|
||||
void clientVersionId; // mock ไม่ใช้ — id ของ mock สร้างเอง
|
||||
void fileName;
|
||||
void templateId;
|
||||
await delay(500); // ให้ข้อความ progress ของ flow ขึ้นจริง
|
||||
|
||||
const versions = store.get(documentId) ?? [];
|
||||
const versionNumber = versions.reduce((max, item) => Math.max(max, item.record.versionNumber), 0) + 1;
|
||||
const hasPdf = !!pdf && pdf.size > 0;
|
||||
const trimmedMessage = message?.trim();
|
||||
|
||||
const record: VersionRecord = {
|
||||
versionId: generateGuid(),
|
||||
versionNumber,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdByUserId: authService.getUsername() ?? "unknown",
|
||||
contentHash: await mockContentHash(file),
|
||||
message: trimmedMessage || null,
|
||||
hasPdf,
|
||||
fields: fields && Object.keys(fields).length > 0 ? fields : null,
|
||||
};
|
||||
versions.push({ record, docxBlob: file, pdfBlob: pdf ?? null });
|
||||
store.set(documentId, versions);
|
||||
persist();
|
||||
return { hasPdf, versionId: record.versionId, versionNumber };
|
||||
}
|
||||
|
||||
/** FR-6 — version history เรียงใหม่ → เก่า (คืนสำเนากัน caller mutate store ตรง ๆ) */
|
||||
export async function getVersions(documentId: string): Promise<VersionRecord[]> {
|
||||
await delay(200);
|
||||
const versions = store.get(documentId) ?? [];
|
||||
return versions
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((item) => ({ ...item.record }));
|
||||
}
|
||||
|
||||
export interface VersionDownloadLink {
|
||||
url: string;
|
||||
/** ISO-8601 เวลาที่ URL หมดอายุ */
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FR-7 — ขอ presigned URL อายุสั้นของ version หนึ่ง เพื่อเปิดในเบราว์เซอร์ของผู้ใช้
|
||||
* (webview ของ Word ทิ้ง blob download เงียบๆ — ให้เบราว์เซอร์จริงจัดการแทน)
|
||||
* mock: ไม่มี URL จริงให้คืน — blob:/data: ใช้ผ่าน openBrowserWindow ไม่ได้ (blob ผูกกับ
|
||||
* context ของ webview, data: โดนเบราว์เซอร์ block) ส่วน about:blank ที่คืนไปนี้ host จะ
|
||||
* ปฏิเสธเงียบๆ เพราะ openBrowserWindow เปิดเฉพาะ http/https และเป็น void API ไม่รายงานกลับ
|
||||
* — กดปุ่มดาวน์โหลด/PDF ตอน mock จึงไม่มีหน้าต่างเบราว์เซอร์เปิด (UI ยังเดินครบ flow)
|
||||
*/
|
||||
export async function getDownloadLink(
|
||||
documentId: string,
|
||||
versionId: string,
|
||||
format?: "docx" | "pdf"
|
||||
): Promise<VersionDownloadLink> {
|
||||
void documentId;
|
||||
void versionId;
|
||||
void format;
|
||||
await delay(150);
|
||||
return { url: "about:blank", expiresAt: new Date(Date.now() + 10 * 60_000).toISOString() };
|
||||
}
|
||||
|
||||
/** สร้าง clientVersionId ใหม่สำหรับ upload ครั้งถัดไป */
|
||||
export function newClientVersionId(): string {
|
||||
return generateGuid();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue