N7 ส่งออกรายการ bookmark เป็น JSON แทนรายงาน PDF (เปลี่ยน deliverable ตามคำขอผู้ใช้ 2026-09-17) — route /api/bookmarks/export เขียนใหม่จาก PDFDocument.create ทั้งชุด (ฟอนต์ Sarabun/layout A4/แบ่งหน้า/ต้นไม้ indent 3 ระดับ รวม ~190 บรรทัด) เป็น JSON ข้อมูลล้วนจาก DB: validate fileId (UUID → 400) + ไม่พบไฟล์ = 404 → query bookmarks ทั้งไฟล์ orderBy pageIndex → yOffset → createdAt (ลำดับ N5 เดียวกับ sidebar) → envelope { fileId, title, exportedAt (ISO), bookmarks: BookmarkDTO[] } ผ่าน toBookmarkDTO ตรง ๆ (invariant #5) เก็บ parentId ครบ lossless (reconstruct ต้นไม้จากข้อมูลเองได้) จึงถอด build tree/cycle guard/ฟอนต์/layout ทิ้งหมด; pretty-print 2 space + content-type application/json; charset=utf-8 + attachment filename* จาก encodeRFC5987("{title}-bookmarks.json") (pattern F8/F16); 0 bookmark = "bookmarks": [] ไม่ 404 (ปุ่มทำงานเสมอ); ไม่โหลดไฟล์เอกสารต้นฉบับเลย (invariant #1) + read-only ไม่ publish event ไม่ revalidatePath + ปุ่มท้ายแถว toggle เงื่อนไขค้นหา N6 ในแท็บ "ที่คั่นหน้า" เปลี่ยนไอคอน Download → Braces ({ }) พร้อม title/aria-label "เป็น JSON" (ยังเป็นลิงก์ธรรมดา pattern Export CSV F16 — ส่งออกทุก bookmark ของไฟล์เสมอ ไม่ผูกเงื่อนไขค้นหา N6) + PLAN-FEAT.md §7 N7 เขียนสเปกใหม่เป็น JSON + PLAN-BOOKMARK-PDF.md ติด note หัวไฟล์ว่าโค้ด PDF ในแผนเป็น historical record (สเปกปัจจุบันอยู่ PLAN-FEAT) + gap-analysis ปิด gap กลุ่ม B 5.3.11 ✅ (N5) 5.3.12 ✅ (N6) 5.3.14 ✅ (API นี้) 5.3.15 ✅ (export PDF ของ F8 ฝัง bookmark เป็น outline ติดไปกับไฟล์) พร้อม header อัปเดตล่าสุด 2026-09-17 — ไฟล์เดียวกันมี 5.3.19/5.3.22 ✅ mock login/5.3.23 ของงาน login ตามไปด้วย

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-17 13:43:00 +07:00
parent 65b72cf817
commit 55d09ce5c3
5 changed files with 48 additions and 220 deletions

View file

@ -326,15 +326,13 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15
- **Server Action `searchBookmarks`** (read-only — **ไม่ publish event ไม่ `revalidatePath`** เพราะไม่เขียน DB): คำค้น ILIKE บน `title`+`note` (escape `%`/`_`/`\` เหมือน F14) AND ช่วงหน้า (input **1-based** ตรงที่ผู้ใช้เห็น) AND ผู้บันทึก (`user_id`) AND ช่วงวันที่บันทึก (`YYYY-MM-DD` ตีความเป็นเขตเวลา **+07:00 คงที่** — ไทยไม่มี DST) — ทุกเงื่อนไข optional แต่ต้องมีอย่างน้อยหนึ่งอย่าง; คู่ช่วงกลับด้าน (from > to) = swap เงียบ ๆ; `orderBy pageIndex, yOffset, createdAt` ตรงลำดับการแสดงผลของ N5
- **UI:** block ค้นหา**เฉพาะแท็บ "ที่คั่นหน้า"** ใน sidebar (แยกจากช่อง F14 ที่อยู่เหนือแท็บ — ช่องเดียวค้นคนละอย่างตามแท็บ = สับสน) — ช่องคำค้น + ปุ่มกางแถบเงื่อนไข (ช่วงหน้า from–to / ผู้บันทึก Select จาก unique `userId` ใน `bookmarks` prop — **ห้ามปนกับ dropdown กรอง F4** ที่ derive จาก annotations / วันที่ from–to แบบ `type="date"` + ปุ่มล้างเงื่อนไข) — q ว่างแต่มีเงื่อนไขอื่น**ค้นได้** (ต่างจาก F14); ผลลัพธ์เป็น list แบนแสดง**แทนต้นไม้ชั่วคราว** (แถว: title + "หน้า N · ผู้บันทึก" + "ใน: `<ชื่อแม่>`" ถ้าเป็น sub + โน้ต clamp, คลิก = `onGoToBookmark`) จนกว่าจะกดปิด — input ทุกตัว `onKeyDown` `stopPropagation` กันชนคีย์ลัด H/D/C/B ของ viewer
- **Realtime:** ไม่มี event ใหม่ — `user_name` ไหลไปกับ DTO ของ create/update event เดิมอัตโนมัติ
- **Out of scope ของ N6:** bookmark ในหน้าสรุป F15/CSV (ส่วนรายงาน PDF ทำแล้วใน N7), ค้นข้ามหลายไฟล์ (gap 5.3.10), full-text search
- **Out of scope ของ N6:** bookmark ในหน้าสรุป F15/CSV (ส่วนส่งออก bookmark ทำแล้วใน N7), ค้นข้ามหลายไฟล์ (gap 5.3.10), full-text search
### N7 · รายงานรายการ bookmark เป็น PDF (2026-09-16)
### N7 · ส่งออกรายการ bookmark เป็น JSON (2026-09-16 · เปลี่ยนจากรายงาน PDF 2026-09-17)
> แผนงานฉบับเต็ม: `docs/PLAN-BOOKMARK-PDF.md` — requirement 5.3.15 (gap analysis กลุ่ม B ข้อสุดท้าย)
> แผนงานฉบับเต็ม (ตอนเป็น PDF): `docs/PLAN-BOOKMARK-PDF.md` — requirement 5.3.15 (gap analysis กลุ่ม B ข้อสุดท้าย) · 2026-09-17 ผู้ใช้ขอเปลี่ยน deliverable เป็น JSON แทนรายงาน PDF
- **Route:** `app/api/bookmarks/export/route.ts` (GET `?fileId=`) — สร้างเอกสาร PDF **ใหม่ล้วน** (`PDFDocument.create()` — ไม่โหลดไฟล์เอกสารต้นฉบับเลย: invariant #1 โดยการสร้าง ไม่ใช่การก๊อป ไม่เกี่ยว `readPublicPdf`): หัวรายงาน (หน้าแรกหน้าเดียว: "รายการที่คั่นหน้า" + ชื่อไฟล์ + "สร้างเมื่อ {วันที่} · N รายการ" + เส้นคั่น) + **ต้นไม้ indent 3 ระดับ** (indent 14pt/ระดับ; ระดับ 1 = 13pt, 2–3 = 11pt — Regular ตัวเดียว ลำดับชั้นด้วย indent+ขนาด ไม่ใช้ตัวหนา) เรียงตาม N5 (`pageIndex → yOffset → createdAt` — query เรียงมาเลย ลูกเรียงตามเกณฑ์เดียวกันอัตโนมัติ) แต่ละรายการ: ชื่อ (wrap `wrapTextByWidth`) + metadata 9pt เทา "หน้า N · {userName || "ไม่ทราบชื่อ"} · {วันที่บันทึก `toLocaleDateString("th-TH")`}" + โน้ต "โน้ต: …" ใต้ metadata; A4 + แบ่งหน้าต่อบรรทัด (guard แถวสูงเกินหน้า) + footer "หน้า n จาก N" ทุกหน้า; embed `Sarabun-Regular.ttf` **จำเป็น** (รายงานเป็นข้อความล้วน — ไม่มีไฟล์ = 500 ข้อความชัด ต่างจาก D5 ของ F8 ที่ optional); 0 bookmark = PDF หน้าเดียวข้อความ "ยังไม่มีรายการที่คั่นหน้าในไฟล์นี้" (ไม่ 404 — ปุ่มทำงานเสมอ); response `attachment` + `filename*` จาก `encodeRFC5987("{title}-bookmarks.pdf")` (pattern F8/F16)
- **ต้นไม้ defensive:** orphan `parentId` → เลื่อนขึ้น root เหมือน sidebar/buildOutline; cycle (ข้อมูลแก้ตรง DB หลุด guard ของ action) → DFS ด้วย **seen ชุดเดียวร่วมทุก root** กันเดินซ้ำ/วนไม่รู้จบ แล้ว**เติมโหนดที่ไม่มี root เข้าถึงเป็นรายการระดับ 1 ท้ายรายการ** (ข้อมูลไม่หาย — รวมเคสวงจรปน root ปกติ และเคสวงจรล้วนทั้งไฟล์ = ออกมาแบนทั้งหมดระดับ 1 จากกลไกเดียวกัน)
- **การวาด (ระวัง 2 จุด):** วาดบรรทัดต้อง **advanceLine ก่อนแล้วอ่าน `page` ทีหลัง** — ฝัง `advanceLine()` ใน argument ของ `page.drawText(...)` แล้ว base ของ method call ถูก resolve ก่อน arguments ตาม spec ทำให้บรรทัดแรกของหน้าใหม่ (ที่เพิ่ง reassign `page`) ถูกวาดกลับลง**หน้าเก่า**ซ้อนบรรทัดบนสุด; **ชื่อไฟล์ในหัวรายงาน wrap ด้วย `wrapTextByWidth`** เหมือนรายการ (title ใน DB ยาวได้ถึง 200 ตัวอักษร)
- **UI:** ปุ่มไอคอน `Download` ท้ายแถว toggle เงื่อนไขค้นหา N6 ในแท็บ "ที่คั่นหน้า" — `<a href="/api/bookmarks/export?fileId=">` ลิงก์ธรรมดา (pattern Export CSV F16 — ไม่มี JS/state) + `coarse-36` — ส่งออก**ทุก bookmark ของไฟล์เสมอ** (ไม่ผูกเงื่อนไขค้นหา N6 ที่ตั้งไว้)
- **Route:** `app/api/bookmarks/export/route.ts` (GET `?fileId=`) — **ข้อมูลล้วนจาก DB ไม่โหลดไฟล์เอกสารต้นฉบับเลย** (invariant #1): validate `fileId` (UUID → 400) + หาไฟล์ไม่เจอ = 404 แล้ว query `bookmarks` ทั้งไฟล์ `orderBy asc(pageIndex), asc(yOffset), asc(createdAt)` (ลำดับเดียวกับ sidebar/N5) → response JSON `{ fileId, title, exportedAt (ISO), bookmarks: BookmarkDTO[] }` — item เป็น `toBookmarkDTO` ตรง ๆ (camelCase + ISO string, invariant #5) เก็บ `parentId` ครบ **lossless** reconstruct ต้นไม้ได้จากข้อมูลเอง จึงไม่ต้อง build tree/cycle guard/ฟอนต์/layout แบบตอนเป็น PDF; pretty-print 2 space; 0 bookmark = `"bookmarks": []` (ไม่ 404 — ปุ่มทำงานเสมอ); `content-type: application/json; charset=utf-8` + `attachment` + `filename*` จาก `encodeRFC5987("{title}-bookmarks.json")` (pattern F8/F16)
- **UI:** ปุ่มไอคอน `Braces` ({ }) ท้ายแถว toggle เงื่อนไขค้นหา N6 ในแท็บ "ที่คั่นหน้า" — `<a href="/api/bookmarks/export?fileId=">` ลิงก์ธรรมดา (pattern Export CSV F16 — ไม่มี JS/state) + `coarse-36` — ส่งออก**ทุก bookmark ของไฟล์เสมอ** (ไม่ผูกเงื่อนไขค้นหา N6 ที่ตั้งไว้)
- **Read-only:** ไม่ publish event ไม่ `revalidatePath` (ไม่เขียน DB — เหมือน `searchBookmarks`/route export เดิม)
- **Out of scope ของ N7:** export ตามเงื่อนไขค้นหา N6, รวม annotation/เพิ่ม bookmark ลงหน้าสรุป F15 หรือ CSV, ตัวหนา (`Sarabun-Bold.ttf`), export ข้ามหลายไฟล์ (gap 5.3.10), ตัวเลือกหน้ากระดาษ/แนวนอน/config
- **Out of scope ของ N7:** export ตามเงื่อนไขค้นหา N6, รวม annotation/เพิ่ม bookmark ลงหน้าสรุป F15 หรือ CSV, กลับไปเป็นรายงาน PDF (ถอดแล้ว — จะเอาคืนดูแผนเดิม `docs/PLAN-BOOKMARK-PDF.md`), export ข้ามหลายไฟล์ (gap 5.3.10)