18 KiB
แผน 5.3.15 — รายงานรายการ bookmark เป็น PDF (N7)
อัปเดต 2026-09-17: ผู้ใช้ขอเปลี่ยน deliverable ของ N7 เป็นส่งออก JSON แทนรายงาน PDF — route เดิม (
app/api/bookmarks/export/route.ts) ถูกเขียนใหม่เป็น JSON (flat arrayBookmarkDTOเรียง N5 + envelope{ fileId, title, exportedAt, bookmarks }) โค้ด PDF ในแผนนี้จึงเป็น historical record — สเปกปัจจุบันอยู่ที่docs/PLAN-FEAT.md§7 N7 (ถอด: build tree/cycle guard/ฟอนต์/layout ทั้งหมด)
สถานะ: 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 analysisdocs/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)
- Entry point: ปุ่มไอคอนเล็กในแท็บ "ที่คั่นหน้า" ของ sidebar (ใน block ค้นหา N6) — จุดเดียว ไม่วางที่หน้าสรุป F15
- เลย์เอาต์: ต้นไม้ indent 3 ระดับ เหมือน sidebar เป๊ะ — เรียงตามลำดับหน้า (N5) แต่ละรายการ: ชื่อ + metadata (หน้า · ผู้บันทึก · วันที่บันทึก) + โน้ตใต้ชื่อ
- ขอบเขตข้อมูล: ทุก 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 "ไม่พบไฟล์นี้" · querybookmarksทั้งไฟล์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+ classcoarse-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 (ออกแบบไว้ล่วงหน้า)
- ชื่อ/โน้ตยาวเกินหน้า →
wrapTextByWidthตัดทีละตัวอักษร (ไทยไม่มีช่องว่าง) + ขึ้นหน้าใหม่; แถวเดียวสูงเกินหน้าเปล่าทั้งหน้า → วาดต่อ (guard infinite loop) - orphan
parentId(แม่หาย) → เลื่อนขึ้น root เหมือน sidebar และ buildOutline - cycle ใน DB (แก้ตรง ๆ หลุด guard ของ action) → seen ร่วมทุก root กันเดินซ้ำ + โหนดที่ไม่มี root เข้าถึงถูกเติมเป็นระดับ 1 ท้ายรายการ — ทั้งวงจรล้วนและวงจรปน root ปกติ ไม่พัง ไม่ซ้ำ ไม่หาย
userName = ''(row เดิมก่อน backfill N6) → "ไม่ทราบชื่อ"- ลึกเกิน 3 ระดับใน DB (แก้ตรง ๆ) → DFS วาดตามจริง indent ต่อเนื่อง — ไม่พัง แค่ยาว (action กัน ≤ 3 อยู่แล้ว)
- 0 bookmark → PDF หน้าเดียว + "ยังไม่มีรายการที่คั่นหน้าในไฟล์นี้" (ปุ่มไม่เคย error)
- ชื่อไฟล์/ชื่อ bookmark ภาษาไทย + อักขระพิเศษ →
PDFHexString/drawText กับ Sarabun embed (pattern เดียวกับ TEXTBOX export F8) + filenamefilename*RFC 5987 - หน้าเอกสารเกินจริง (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)
npm run dev— เปิดไฟล์ที่มี bookmark ต้นไม้ 3 ระดับกระจัดกระจายหลายหน้า → กดปุ่มในแท็บที่คั่นหน้า → ได้ไฟล์ PDF ชื่อ{title}-bookmarks.pdf- เปิดรายงานตรวจ: ลำดับ = N5 (หน้า → ตำแหน่งบนหน้า → เวลา), indent 3 ระดับ, metadata ครบ หน้า/ผู้บันทึก/วันที่, โน้ตใต้ชื่อ, footer เลขหน้า, ข้อความไทย render ถูกด้วย Sarabun
- Regression: ค้นหา N6 + ต้นไม้ bookmark + export PDF F8 + Export CSV F16 ยังทำงานเหมือนเดิม