diff --git a/internal/handlers/conflicts.go b/internal/handlers/conflicts.go index 6d2bd2cd..4c97b6dd 100644 --- a/internal/handlers/conflicts.go +++ b/internal/handlers/conflicts.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "errors" + "log" "net/http" "time" @@ -103,26 +104,10 @@ func (h *ConflictHandler) GetConflictsData(c *echo.Context) ([]ConflictDetailRes continue } - detail := ConflictDetailResponse{ - ID: uuid.UUID(conflict.ID.Bytes).String(), - MediaItemID: uuid.UUID(conflict.MediaItemID.Bytes).String(), - MediaItemTitle: conflict.Title, - ConflictType: conflict.ConflictType, - ConflictData: conflictData, - ResolutionStatus: conflict.ResolutionStatus.String, - CreatedAt: conflict.CreatedAt.Time, - } - - if conflict.ResolvedBy.Valid { - detail.ResolvedBy = uuid.UUID(conflict.ResolvedBy.Bytes).String() - } - if conflict.ResolvedAt.Valid { - detail.ResolvedAt = &conflict.ResolvedAt.Time - } - if conflict.ResolutionData != nil { - if err := json.Unmarshal(conflict.ResolutionData, &detail.ResolutionData); err == nil { - } - } + detail := NewConflictDetail(conflict.ID, conflict.MediaItemID, + conflict.Title, conflict.ConflictType, conflict.ResolutionStatus.String, + conflictData, conflict.CreatedAt, conflict.ResolvedAt, conflict.ResolvedBy, + conflict.ResolutionData) response = append(response, detail) if conflict.ResolutionStatus.String == "unresolved" { @@ -133,6 +118,41 @@ func (h *ConflictHandler) GetConflictsData(c *echo.Context) ([]ConflictDetailRes return response, len(response), unresolvedCount, nil } +// NewConflictDetail maps any sync-conflict row shape (list-by-user, +// get-by-id, list-by-media-item) to the wire detail. The resolution +// tail is conditional exactly as before; a malformed resolution blob +// now logs instead of vanishing silently. +func NewConflictDetail( + id, mediaItemID pgtype.UUID, + title, conflictType, resolutionStatus string, + conflictData map[string]ConflictSourceData, + createdAt, resolvedAt pgtype.Timestamptz, + resolvedBy pgtype.UUID, + resolutionData []byte, +) ConflictDetailResponse { + detail := ConflictDetailResponse{ + ID: uuid.UUID(id.Bytes).String(), + MediaItemID: uuid.UUID(mediaItemID.Bytes).String(), + MediaItemTitle: title, + ConflictType: conflictType, + ConflictData: conflictData, + ResolutionStatus: resolutionStatus, + CreatedAt: createdAt.Time, + } + if resolvedBy.Valid { + detail.ResolvedBy = uuid.UUID(resolvedBy.Bytes).String() + } + if resolvedAt.Valid { + detail.ResolvedAt = &resolvedAt.Time + } + if resolutionData != nil { + if err := json.Unmarshal(resolutionData, &detail.ResolutionData); err != nil { + log.Printf("conflict %s: unparsable resolution data: %v", detail.ID, err) + } + } + return detail +} + func (h *ConflictHandler) ListConflicts(c *echo.Context) error { conflicts, total, unresolved, err := h.GetConflictsData(c) if err != nil { @@ -177,26 +197,10 @@ func (h *ConflictHandler) GetConflict(c *echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, "failed to parse conflict data") } - detail := ConflictDetailResponse{ - ID: uuid.UUID(conflict.ID.Bytes).String(), - MediaItemID: uuid.UUID(conflict.MediaItemID.Bytes).String(), - MediaItemTitle: mediaItem.Title, - ConflictType: conflict.ConflictType, - ConflictData: conflictData, - ResolutionStatus: conflict.ResolutionStatus.String, - CreatedAt: conflict.CreatedAt.Time, - } - - if conflict.ResolvedBy.Valid { - detail.ResolvedBy = uuid.UUID(conflict.ResolvedBy.Bytes).String() - } - if conflict.ResolvedAt.Valid { - detail.ResolvedAt = &conflict.ResolvedAt.Time - } - if conflict.ResolutionData != nil { - if err := json.Unmarshal(conflict.ResolutionData, &detail.ResolutionData); err == nil { - } - } + detail := NewConflictDetail(conflict.ID, conflict.MediaItemID, + mediaItem.Title, conflict.ConflictType, conflict.ResolutionStatus.String, + conflictData, conflict.CreatedAt, conflict.ResolvedAt, conflict.ResolvedBy, + conflict.ResolutionData) return c.JSON(http.StatusOK, detail) } diff --git a/internal/handlers/devices.go b/internal/handlers/devices.go index a54d4721..5d2f5a23 100644 --- a/internal/handlers/devices.go +++ b/internal/handlers/devices.go @@ -217,6 +217,17 @@ func (h *DeviceHandler) CheckRegistrationStatus(c *echo.Context) error { }) } +// koreaderSyncEndpoints is the KOReader endpoint map shared by the +// surfaces that publish it (KOReader authenticates by header, so its +// URLs never embed the token). +func koreaderSyncEndpoints(baseURL string) map[string]string { + return map[string]string{ + "progress": fmt.Sprintf("%s/api/sync/koreader/progress", baseURL), + "metadata": fmt.Sprintf("%s/api/sync/koreader/metadata", baseURL), + "bookmarks": fmt.Sprintf("%s/api/sync/koreader/bookmarks", baseURL), + } +} + // deviceToInfo is the single Devices->DeviceInfo mapper for every // device surface. AuthToken rides only on surfaces that deliberately // expose it (the owner's device lists). @@ -483,9 +494,7 @@ func (h *DeviceHandler) RegenerateDeviceToken(c *echo.Context) error { syncURLs["bookmark"] = fmt.Sprintf("%s/api/sync/kobo/%s/bookmark", baseURL, newToken) syncURLs["init"] = fmt.Sprintf("%s/api/sync/kobo/%s/v1/initialization", baseURL, newToken) case "koreader": - syncURLs["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", baseURL) - syncURLs["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", baseURL) - syncURLs["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", baseURL) + syncURLs = koreaderSyncEndpoints(baseURL) } // Handle HTMX requests - return HTML with page reload script @@ -626,9 +635,7 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error { syncEndpoints := map[string]string{} switch registration.DeviceType { case "koreader": - syncEndpoints["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", h.cfg.BaseURL) - syncEndpoints["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", h.cfg.BaseURL) - syncEndpoints["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", h.cfg.BaseURL) + syncEndpoints = koreaderSyncEndpoints(h.cfg.BaseURL) case "kobo": syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL) syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL) diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 52d6bee0..7cc5a0bc 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -1590,15 +1590,11 @@ func registerFrontendRoutes(cfg *Config) { if conf.ResolutionStatus.Valid && conf.ResolutionStatus.String == "unresolved" { var conflictData map[string]handlers.ConflictSourceData if err := json.Unmarshal(conf.ConflictData, &conflictData); err == nil { - activeConflict = &handlers.ConflictDetailResponse{ - ID: uuid.UUID(conf.ID.Bytes).String(), - MediaItemID: uuid.UUID(conf.MediaItemID.Bytes).String(), - MediaItemTitle: mediaItem.Title, - ConflictType: conf.ConflictType, - ConflictData: conflictData, - ResolutionStatus: conf.ResolutionStatus.String, - CreatedAt: conf.CreatedAt.Time, - } + detail := handlers.NewConflictDetail(conf.ID, conf.MediaItemID, + mediaItem.Title, conf.ConflictType, conf.ResolutionStatus.String, + conflictData, conf.CreatedAt, conf.ResolvedAt, conf.ResolvedBy, + conf.ResolutionData) + activeConflict = &detail } break }