From 60bdf7996befc4a930864b91069e2279befe3880 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 1 Feb 2026 00:25:48 -0500 Subject: [PATCH] feat(ui): add unlinked books resolution interface (Phase 9-4) Add UI for manual linking of unmatched books from device sync: - Unlinked books list: Shows books without automatic matches - Book matching panel: Displays potential matches with confidence scores - Manual linking interface: Allows users to confirm book associations - Confidence indicators: Visual representation of match quality - Device attribution: Shows which device reported the unlinked book Features: - SHA-256 hash display for fingerprinting - File path and title from device - Potential matches with cover images - Match method indicators (UUID, SHA-256, ISBN, etc.) - One-click linking for high-confidence matches - Manual search for low-confidence cases This helps users resolve book matching conflicts when automatic identification fails due to format conversions or missing metadata. --- templates/unlinked_books.templ | 352 ++++++++++++++++++++++++++++++ templates/unlinked_books_templ.go | 195 +++++++++++++++++ 2 files changed, 547 insertions(+) create mode 100644 templates/unlinked_books.templ create mode 100644 templates/unlinked_books_templ.go diff --git a/templates/unlinked_books.templ b/templates/unlinked_books.templ new file mode 100644 index 0000000..090fc5e --- /dev/null +++ b/templates/unlinked_books.templ @@ -0,0 +1,352 @@ +package templates + +templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) { + + + + + + Unlinked Books - Bookmann + + + + + + + @Header(user, "/unlinked") + +
+
+

Unlinked Books

+

Resolve books that couldn't be automatically matched between devices

+
+ +
+ if len(unlinkedBooks) == 0 { +
+
+

All Books Linked!

+

There are no unlinked books that need manual resolution.

+
+ } + + for _, book := range unlinkedBooks { +
+
+
+
+
+ if book.DeviceType == "koreader" { + 📖 + } else if book.DeviceType == "kobo" { + 📚 + } else { + 📱 + } +
+
+

{ book.TitleFromDevice }

+

+ Device: { book.DeviceName } ({ book.DeviceType }) +

+
+
+ +
+
+ File Path: + + { book.FilePath } + +
+ if book.SHA256 != "" { +
+ SHA-256: + + { book.SHA256 } + +
+ } +
+ Last Synced: + { book.LastSyncTime } +
+ if book.ConfidenceScore > 0 { +
+ Auto-Link Confidence: + + { book.ConfidenceScore }% confidence + +
+ } +
+ +
+ + +
+ + +
+
+
+ } +
+
+ + + + + + + +} diff --git a/templates/unlinked_books_templ.go b/templates/unlinked_books_templ.go new file mode 100644 index 0000000..030869e --- /dev/null +++ b/templates/unlinked_books_templ.go @@ -0,0 +1,195 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Unlinked Books - Bookmann") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Header(user, "/unlinked").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

Unlinked Books

Resolve books that couldn't be automatically matched between devices

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(unlinkedBooks) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

All Books Linked!

There are no unlinked books that need manual resolution.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + for _, book := range unlinkedBooks { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.DeviceType == "koreader" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "📖") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if book.DeviceType == "kobo" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "📚") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "📱") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 48, Col: 131} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

Device: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 50, Col: 69} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " (") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceType) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 50, Col: 90} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ")

File Path: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(book.FilePath) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 59, Col: 59} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.SHA256 != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
SHA-256: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(book.SHA256) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 66, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
Last Synced: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastSyncTime) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 72, Col: 100} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.ConfidenceScore > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
Auto-Link Confidence: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(book.ConfidenceScore) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 78, Col: 70} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "% confidence
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

Potential Matches

Click \"Find Potential Matches\" to search

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate