eCourt-note/docs/PLAN-BOOKMARK-PDF.md
Your Name f4d46c7720 external-api: N10 API ดึงข้อมูลให้ระบบภายนอกด้วย API Key + ถอด F16 export CSV / N7 bookmark JSON แทนด้วย N10 — (1) endpoint เดียว POST /api/external/documents (JSON body {fileId และ/หรือ typeId — ส่งทั้งคู่ = AND เหลือเอกสารเดียว, filter:{type bookmarks|annotations, userId string|array, annotationTypes ค่า enum}}) ให้ backend e-Court เรียก server-to-server ผ่าน header x-api-key คืน {documents:[…]} รูปเดียวเสมอ — bundle ยุบชั้น field เอกสาร + annotations (พร้อม url deep link + replies) + bookmarks ระดับเดียวกัน, url เป็น URL เต็ม origin จาก request ผู้เรียก (x-forwarded-proto/host fallback host ตรง) + basePath (เดิม 2 GET query-param รวมเป็นเส้นเดียว 2026-09-24 · OR→AND + url relative→เต็ม + filter.types→annotationTypes กันสับสนกับ filter.type 2026-09-25) (2) lib/external-api-auth.ts — requireExternalApiKey เทียบ SHA-256 + crypto.timingSafeEqual, env EXTERNAL_API_KEY ไม่ตั้ง/ว่าง = 401 fail closed, ข้อความ 401 เดียวกันกัน enumerate + externalApiError คืน JSON {"error": ไทย} ใช้ร่วมทุก route — module DB-free ให้ tests/lib/external-api-auth.test.ts (5 tests) รันได้ไม่ต้องมี DATABASE_URL (3) lib/external-api.ts parseQueryBody strict กัน typo เงียบ ๆ (field นอก 3 ตัว = 400, annotationTypes ค่านอก enum = 400 บอกค่าที่รับ) + loadDocumentBundles bulk inArray = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ กรอง userIds/annotationTypes ใน SQL เรียง F15/N5 (4) route: auth ก่อนแตะ DB + ครอบ try/catch ทุก DB call ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework, read-only ไม่ publish SSE ไม่ revalidatePath ไม่มี CORS, REDACTION รวมในผลลัพธ์ (5) ถอด F16/N7 ตามคำขอผู้ใช้ 2026-09-25 (N10 ครอบคลุมข้อมูลชุดเดียวกันพร้อม auth): ลบ app/api/annotations/export-csv + app/api/bookmarks/export + utils/csv.ts + tests/utils/csv.test.ts + ปุ่มส่งออก 2 ปุ่มบนหน้าสรุป + ปุ่ม JSON ใน sidebar + รูป requirement 1.png/2.png (บรรทัดอ้างถึงใน gap-analysis.md เปลี่ยนเป็น historical note) (6) docs sync ครบ: PLAN-API.md แผนฉบับเต็ม 9 รอบ + PLAN-FEAT.md §7 N10 + PLAN-BOOKMARK-PDF.md/gap-analysis.md 5.3.14 เปลี่ยนตอบด้วย N10 + CLAUDE.md + README (env/ตัวอย่าง curl/troubleshooting) + .env.example + .claude/rules/data-layer.md — ผ่าน code review 2 รอบ 2026-09-25: แก้ 500 JSON ตอน DB ล้ม + ถอด fan-out ต่อไฟล์เป็น bulk + ถอด as cast แทน type guard (tsc + vitest 149/149) (คำขอผู้ใช้ 2026-09-25)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 12:02:42 +07:00

19 KiB
Raw Blame History

แผน 5.3.15 — รายงานรายการ bookmark เป็น PDF (N7)

อัปเดต 2026-09-17: ผู้ใช้ขอเปลี่ยน deliverable ของ N7 เป็นส่งออก JSON แทนรายงาน PDF — route เดิม (app/api/bookmarks/export/route.ts) ถูกเขียนใหม่เป็น JSON (flat array BookmarkDTO เรียง N5 + envelope { fileId, title, exportedAt, bookmarks }) โค้ด PDF ในแผนนี้จึงเป็น historical record — สเปกปัจจุบันอยู่ที่ docs/PLAN-FEAT.md §7 N7 (ถอด: build tree/cycle guard/ฟอนต์/layout ทั้งหมด)

อัปเดต 2026-09-25: N7 ถูกถอดออกจากโค้ดทั้งหมดตามคำขอผู้ใช้ (route + ปุ่มใน sidebar/หน้าสรุป) — ระบบภายนอกดึงข้อมูล bookmark ผ่าน N10 API (POST /api/external/documents, แผน docs/PLAN-API.md) แทน; ทั้งแผนนี้และ §7 N7 ของ PLAN-FEAT.md เป็น historical record ทั้งคู่แล้ว

สถานะ: implement เสร็จสมบูรณ์ (2026-09-16) — ครบ 4 task, tsc ผ่าน, vitest 120/120, plan-reviewer + code-reviewer ผ่าน (findings ทั้งหมดแก้แล้ว: บรรทัดแรกของหน้าใหม่ถูกวาดกลับลงหน้าเก่า — page.drawText({ y: advanceLine(...) }) ประเมิน base ของ method call ก่อน arguments ตาม spec ทำให้ซ้อนบรรทัดบนสุดของหน้าเก่า → ต้อง advance ก่อนแล้วอ่าน page ทีหลัง, cycle guard ซ้ำ/หาย — seen set ต่อ root ทำให้วงจรล้วนซ้ำ 2 เท่าและวงจรปน root หายเงียบ → seen ร่วมทุก root + เติมโหนดที่ไม่ถูกเดินเป็นระดับ 1, ชื่อไฟล์ในหัวรายงานไม่ wrap — title ยาวถึง 200 ตัวล้นขอบขวา → wrap ด้วย wrapTextByWidth เหมือนรายการ) — re-verify หลังแก้ด้วยไฟล์ทดสอบ (40 รายการ + วงจรปน root + title ยาว 200): 4 หน้า ไม่มีข้อความซ้อนทับเลย ทุกหน้ามีเนื้อหาจริง วงจรโผล่ครบระดับ 1 · ไฟล์วงจรล้วน 2 รายการ = แบนระดับ 1 ทั้งคู่ · ทดสอบเดิมผ่านครบ: ไฟล์จริง 7 bookmarks ต้นไม้ 2 ระดับ indent x48↔x62 ลำดับ N5 ถูก, 40 รายการ = 5 หน้า + footer ทุกหน้า, 0 bookmark = "ยังไม่มีรายการ…", invalid id = 400, ไม่มีไฟล์ = 404, ฟอนต์ไทย render ถูก (ยืนยันสระผสม "คำสั่ง" ผ่าน render PNG + vision OCR — "คำา" ใน text extraction เป็น artifact ของ ToUnicode ตอน subset ไม่กระทบการแสดงผล), ปุ่มโผล่ใน SSR ของหน้า annotate พร้อม href/aria-label ถูก อ้างอิง: requirement 5.3.15 (gap analysis docs/requirement/gap-analysis.md กลุ่ม B — ข้อสุดท้ายที่เหลือ: 5.3.11 = N5 ✅, 5.3.12 = N6 ✅) ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง docs/PLAN-FEAT.md §7 (N7 รายงานรายการ bookmark เป็น PDF) + ขยาย whitelist ใน CLAUDE.md ก่อน — เป็น task ที่ 1 ของแผนนี้ แล้วรันผ่าน /implement-feat N7

Context

  • 5.3.15 (⚠️): ตอนนี้มี export PDF ฝัง annotation + bookmark เป็น PDF outline (F8 — app/api/annotations/export/route.ts) + export CSV สรุป annotation (F16) — แต่ requirement ถามหา "เอกสาร PDF ที่เป็นรายงานรายการ bookmark" เอกสารแยกต่างหาก ไม่ใช่ไฟล์ต้นฉบับ
  • โครงสร้างพร้อมอยู่แล้วทั้งหมด: pdf-lib + fontkit (ใช้ใน F8), ฟอนต์ไทย public/fonts/Sarabun-Regular.ttf (embed ได้เลย), BookmarkDTO มี userName (N6) + โครงสร้างต้นไม้ parentId (N2) + ลำดับ N5 (pageIndex → yOffset → createdAt), wrapTextByWidth/encodeRFC5987 ใน utils/pdf-export.ts

การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-16)

  1. Entry point: ปุ่มไอคอนเล็กในแท็บ "ที่คั่นหน้า" ของ sidebar (ใน block ค้นหา N6) — จุดเดียว ไม่วางที่หน้าสรุป F15
  2. เลย์เอาต์: ต้นไม้ indent 3 ระดับ เหมือน sidebar เป๊ะ — เรียงตามลำดับหน้า (N5) แต่ละรายการ: ชื่อ + metadata (หน้า · ผู้บันทึก · วันที่บันทึก) + โน้ตใต้ชื่อ
  3. ขอบเขตข้อมูล: ทุก bookmark ของไฟล์เสมอ — ไม่ผูกกับเงื่อนไขค้นหา N6 ที่ผู้ใช้ทิ้งไว้

Design

D1 — Route app/api/bookmarks/export/route.ts (GET ?fileId=)

สร้างเอกสาร PDF ใหม่ล้วน (PDFDocument.create()) — ไม่โหลด/ไม่แตะไฟล์เอกสารต้นฉบับเลย (invariant #1 โดยการสร้าง ไม่ใช่การก๊อป — ไม่เกี่ยว readPublicPdf):

  • validate + query ตาม pattern route export เดิม (F8/F16): fileId ผ่าน UUID → ไม่ผ่าน = 400 "fileId ไม่ถูกต้อง" · หา files ไม่เจอ = 404 "ไม่พบไฟล์นี้" · query bookmarks ทั้งไฟล์ orderBy asc(pageIndex), asc(yOffset), asc(createdAt) (ลำดับเดียวกับ searchBookmarks/N5)
  • ต้นไม้: build childrenOf + roots จาก array ที่เรียงแล้ว (เดินตามลำดับ = ลูกเรียงตาม N5 อัตโนมัติ) — orphan (parentId ไม่อยู่ใน set) → เลื่อนขึ้น root เหมือน sidebar/buildOutline · cycle guard (ข้อมูลถูกแก้ตรง DB หลุด guard ของ action): DFS ด้วย seen ชุดเดียวร่วมทุก root กันเดินซ้ำ/วนไม่รู้จบ แล้วเติมโหนดที่ไม่มี root เข้าถึงเป็นรายการระดับ 1 ท้ายรายการ — ครอบทั้งวงจรปน root ปกติ (ไม่หายเงียบ) และวงจรล้วนทั้งไฟล์ (ออกมาแบนระดับ 1 ทั้งหมด)
  • ฟอนต์: embed public/fonts/Sarabun-Regular.ttf ด้วย fontkit (subset: true เหมือน F8) — จำเป็น (รายงานเป็นข้อความล้วน): ไม่มีไฟล์ = 500 ข้อความชัด "ขาดไฟล์ฟอนต์ Sarabun-Regular.ttf" (ต่างจาก D5 ของ F8 ที่ optional เพราะ annotation ยังวาดรูปได้ — รายงานไม่มีฟอนต์ = ไร้ค่า) · ใช้ Regular ตัวเดียว — ลำดับชั้นทำด้วย indent + ขนาด ไม่ใช้ตัวหนา
  • เลย์เอาต์ A4 portrait (595.28×841.89pt) margin 48pt:
    • หัวรายงาน (หน้าแรกหน้าเดียว): "รายการที่คั่นหน้า" 16pt · ชื่อไฟล์ 12pt · "สร้างเมื่อ {วันที่ export} · N รายการ" 9pt สีเทา (#64748b) + เส้นคั่นใต้หัว
    • แถว bookmark (DFS ตามต้นไม้): ชื่อ — indent 14pt ต่อระดับ, ระดับ 1 = 13pt / ระดับ 2–3 = 11pt, wrap ด้วย wrapTextByWidth (ชื่อยาว) → บรรทัด metadata 9pt เทา: "หน้า {pageIndex+1} · {userName || "ไม่ทราบชื่อ"} · {createdAt format วันที่}" → ถ้ามีโน้ต: "โน้ต: …" 9pt wrap ใต้ metadata
    • วันที่: toLocaleDateString("th-TH", { timeZone: "Asia/Bangkok" }) (แสดง พ.ศ. อัตโนมัติ — วันที่อย่างเดียว ไม่มีเวลา ต่างจาก CSV ที่เป็น report เชิงอ่าน)
    • ระยะห่าง 8pt ต่อรายการ · 12pt ก่อนรายการระดับ 1 ที่ไม่ใช่ตัวแรก (แยกกลุ่มหัวข้อให้เห็น)
    • แบ่งหน้า: y เลย bottom margin → addPage วาดต่อ (หัวรายงานไม่ซ้ำ) — guard: ถ้าเพิ่งขึ้นหน้าใหม่แล้วแถวยังสูงเกินหน้า วาดต่ออยู่ดี (กัน infinite loop กับโน้ตยาวเกินหน้า) · จบแล้ววาด footer "หน้า n จาก N" กลางล่าง 9pt ทุกหน้า
    • 0 bookmark: ยังสร้าง PDF หน้าเดียวพร้อมข้อความ "ยังไม่มีรายการที่คั่นหน้าในไฟล์นี้" (ไม่ 404 — ปุ่มทำงานเสมอ ทำนายผลได้)
  • Response: content-type: application/pdf + content-disposition: attachment; filename="bookmarks.pdf"; filename*=UTF-8''${encodeRFC5987(file.title + "-bookmarks.pdf")} (pattern เดียวกับ F8 -annotated.pdf / F16 -summary.csv)
  • Read-only ล้วน: ไม่ publish event ไม่ revalidatePath (เหมือน searchBookmarks/route export เดิม)

D2 — ปุ่มในแท็บ "ที่คั่นหน้า" ของ sidebar

components/sidebar/AnnotationSidebar.tsx — ใน block ค้นหา N6 (แสดงเฉพาะ tab === "bookmarks"):

  • ปุ่มไอคอน Download (lucide) shrink-0 วาดท้ายแถว toggle เงื่อนไข (ถัดจาก chevron) เป็น <a href={/api/bookmarks/export?fileId=${fileId}}> ลิงก์ธรรมดา — ไม่มี JS ไม่มี state (pattern เดียวกับปุ่ม Export CSV หน้าสรุป F16) — fileId เป็น prop อยู่แล้ว
  • title + aria-label = "ส่งออกทุกที่คั่นหน้าของไฟล์นี้เป็น PDF" (บอก scope ตามการตัดสินใจ #3 — ไม่ขึ้นกับเงื่อนไขค้นหา) + FOCUS_RING_CLS + class coarse-36 (เป้าจิ้ม 36px บนจอสัมผัส — pattern ปุ่ม sidebar)
  • แสดงทุกมุมมองของแท็บ (ต้นไม้ / ผลการค้นหา) เพราะ block ค้นหาอยู่คงที่เหนือพื้นที่เนื้อหาเสมอ

Docs/rules sync (plan-first — ทำเป็น task แรก ก่อนแตะโค้ด)

ไฟล์ สิ่งที่แก้
docs/PLAN-FEAT.md §7 หัวข้อใหม่ N7 · รายงานรายการ bookmark เป็น PDF (format ตาม N5/N6 + อ้างแผนฉบับเต็ม docs/PLAN-BOOKMARK-PDF.md) + ปรับ out of scope ของ N6 ที่อ้าง gap 5.3.15 ให้ชี้มา N7
CLAUDE.md whitelist ฟีเจอร์เสริมหลังแผน เพิ่ม N7 + โครงสร้างไฟล์เพิ่ม app/api/bookmarks/export/route.ts (รายงาน PDF รายการ bookmark — N7)
  • docs/PLAN.md ไม่แตะ (ไม่มี schema/column ใหม่, ไม่มี invariant ใหม่ — เป็น route อ่านอย่างเดียวสร้างไฟล์ใหม่)
  • .claude/rules/*.md ไม่แตะ (route read-only ไม่เขียน DB ไม่เกี่ยว data-layer rule ใหม่; ไม่แตะ viewer/coordinates/realtime)

ลำดับ task + verify (ทุก task: npx tsc --noEmit ผ่าน)

# งาน ไฟล์หลัก Verify เพิ่มเติม
1 Docs sync N7 docs/PLAN-FEAT.md, CLAUDE.md สเปก §7 ครบตาม format N5/N6; whitelist + โครงสร้างไฟล์มี N7/route ใหม่
2 Route รายงาน PDF app/api/bookmarks/export/route.ts Playwright request: ไฟล์ที่มี bookmark ต้นไม้ 3 ระดับ → response %PDF + headers (filename* ไทย) + อ่านกลับด้วย pdf-lib ได้ page count ถูก; เนื้อหาตรวจด้วยการเปิดใน pdf.js viewer ชั่วคราว (วิธีเดิมที่ใช้ verify F8); เคส: fileId ไม่ผ่าน = 400, ไม่มีไฟล์ = 404, 0 bookmark = PDF หน้าเดียวข้อความว่าง, userName = '' = "ไม่ทราบชื่อ", ชื่อ/โน้ตยาว wrap + ขึ้นหน้าใหม่, footer หน้า n/N
3 ปุ่ม sidebar components/sidebar/AnnotationSidebar.tsx Playwright: กดปุ่มเกิด download ชื่อไฟล์ถูก; ปุ่มโผล่ทั้งมุมมองต้นไม้และผลค้นหา; จอ touch เป้า 36px; regression: ค้นหา N6 ยังทำงานครบ 4 เงื่อนไข
4 ปิดงาน — plan-reviewer ผ่านทุก invariant ที่ N7 แตะ (โดยเฉพาะ #1 non-destructive) + code-reviewer + vitest + tsc

Edge cases (ออกแบบไว้ล่วงหน้า)

  1. ชื่อ/โน้ตยาวเกินหน้า → wrapTextByWidth ตัดทีละตัวอักษร (ไทยไม่มีช่องว่าง) + ขึ้นหน้าใหม่; แถวเดียวสูงเกินหน้าเปล่าทั้งหน้า → วาดต่อ (guard infinite loop)
  2. orphan parentId (แม่หาย) → เลื่อนขึ้น root เหมือน sidebar และ buildOutline
  3. cycle ใน DB (แก้ตรง ๆ หลุด guard ของ action) → seen ร่วมทุก root กันเดินซ้ำ + โหนดที่ไม่มี root เข้าถึงถูกเติมเป็นระดับ 1 ท้ายรายการ — ทั้งวงจรล้วนและวงจรปน root ปกติ ไม่พัง ไม่ซ้ำ ไม่หาย
  4. userName = '' (row เดิมก่อน backfill N6) → "ไม่ทราบชื่อ"
  5. ลึกเกิน 3 ระดับใน DB (แก้ตรง ๆ) → DFS วาดตามจริง indent ต่อเนื่อง — ไม่พัง แค่ยาว (action กัน ≤ 3 อยู่แล้ว)
  6. 0 bookmark → PDF หน้าเดียว + "ยังไม่มีรายการที่คั่นหน้าในไฟล์นี้" (ปุ่มไม่เคย error)
  7. ชื่อไฟล์/ชื่อ bookmark ภาษาไทย + อักขระพิเศษ → PDFHexString/drawText กับ Sarabun embed (pattern เดียวกับ TEXTBOX export F8) + filename filename* RFC 5987
  8. หน้าเอกสารเกินจริง (pageIndex พ้น page count ของเอกสาร) → ไม่เกี่ยว — รายงานไม่ผูกเอกสารต้นฉบับ แสดงเลขหน้าจาก DB ตรง ๆ

Out of scope (ไม่ทำในแผนนี้)

  • export ตามเงื่อนไขค้นหา N6 (ผู้ใช้เลือก: ทั้งไฟล์เสมอ)
  • รวม annotation ในรายงาน / เพิ่ม bookmark ลงหน้าสรุป F15 หรือ CSV (F15/F16 ทำหน้าที่สรุป annotation อยู่แล้ว)
  • ตัวหนา (Sarabun-Bold.ttf) — ใช้ Regular ตัวเดียว (repo ไม่มีไฟล์ Bold; ลำดับชั้นด้วย indent + ขนาด)
  • export ข้ามหลายไฟล์ / document set — gap 5.3.10 (กลุ่ม C)
  • ตัวเลือกหน้ากระดาษ/แนวนอน/config หน้ารายงาน

Verification (ภาพรวม end-to-end)

  1. npm run dev — เปิดไฟล์ที่มี bookmark ต้นไม้ 3 ระดับกระจัดกระจายหลายหน้า → กดปุ่มในแท็บที่คั่นหน้า → ได้ไฟล์ PDF ชื่อ {title}-bookmarks.pdf
  2. เปิดรายงานตรวจ: ลำดับ = N5 (หน้า → ตำแหน่งบนหน้า → เวลา), indent 3 ระดับ, metadata ครบ หน้า/ผู้บันทึก/วันที่, โน้ตใต้ชื่อ, footer เลขหน้า, ข้อความไทย render ถูกด้วย Sarabun
  3. Regression: ค้นหา N6 + ต้นไม้ bookmark + export PDF F8 + Export CSV F16 ยังทำงานเหมือนเดิม