fix(middleware): Correct rate limit header type conversion
This commit is contained in:
@@ -0,0 +1,825 @@
|
|||||||
|
# 📋 **Complete Implementation Plan: Fix Kobo Production Code & Add "last-read-place" Support**
|
||||||
|
|
||||||
|
## 🎯 **Executive Summary**
|
||||||
|
|
||||||
|
**Critical Finding**: Kobo DOES send "last-read-place" bookmarks with precise position data, but Bookhoard currently ignores them (falls through switch statement). Adding this support will significantly improve Kobo user experience.
|
||||||
|
|
||||||
|
**Compliance**: All changes follow PROJECT_GUIDELINES.md with:
|
||||||
|
- No local builds (Podman/Docker only)
|
||||||
|
- No breaking changes without testing
|
||||||
|
- Step-by-step implementation (no cascading fix-ups)
|
||||||
|
- Clear git commits with detailed messages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ **Pre-Execution Checklist**
|
||||||
|
|
||||||
|
- [ ] Create backup branch: `git branch backup-before-kobo-comprehensive-fixes`
|
||||||
|
- [ ] Run baseline test suite: `make test-integration > baseline.txt 2>&1`
|
||||||
|
- [ ] Verify containers: `podman compose ps`
|
||||||
|
- [ ] Confirm in build mode (not local)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 **Phase 1: Critical Bug Fixes** (45 minutes)
|
||||||
|
|
||||||
|
### **Step 1: Fix Rate Limit Header Bug**
|
||||||
|
**File**: `/internal/middleware/device_auth.go`
|
||||||
|
**Lines**: 79, 84, 90
|
||||||
|
|
||||||
|
**Current Code**:
|
||||||
|
```go
|
||||||
|
c.Response().Header().Set("X-RateLimit-Remaining", string(rune(remaining)))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue**: Converts int to Unicode character (60 → `<`)
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
```go
|
||||||
|
import "strconv"
|
||||||
|
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verification**:
|
||||||
|
- Edit file
|
||||||
|
- `make build` && `podman compose restart app`
|
||||||
|
- Test rate-limited endpoint with curl
|
||||||
|
- Verify header returns integer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **Step 2: Fix Type Assertion Bug**
|
||||||
|
**File**: `/internal/middleware/device_auth.go`
|
||||||
|
**Lines**: 168-171
|
||||||
|
|
||||||
|
**Current Code**:
|
||||||
|
```go
|
||||||
|
deviceID, ok := c.Get("device_id").(uuid.UUID) // Always fails!
|
||||||
|
if ok {
|
||||||
|
m.db.UpdateDeviceLastSeen(c.Request().Context(), pgDeviceID) // Never executes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue**: `device_id` is `[16]byte`, cast tries `uuid.UUID`
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
```go
|
||||||
|
deviceIDBytes, ok := c.Get("device_id").([16]byte)
|
||||||
|
if ok {
|
||||||
|
pgDeviceID := pgtype.UUID{Bytes: deviceIDBytes, Valid: true}
|
||||||
|
m.db.UpdateDeviceLastSeen(c.Request().Context(), pgDeviceID)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **Step 3: Fix user_id Type Mismatch**
|
||||||
|
**File**: `/internal/middleware/device_auth.go`
|
||||||
|
**Line**: 105
|
||||||
|
|
||||||
|
**Analysis**: No handlers use `c.Get("user_id")`, so line is redundant
|
||||||
|
|
||||||
|
**Fix**: Remove line entirely
|
||||||
|
```go
|
||||||
|
// DELETE: c.Set("user_id", device.UserID.Bytes)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **Step 4: Fix Kobo Nil UUID Error Handling**
|
||||||
|
**File**: `/internal/handlers/kobo.go`
|
||||||
|
**Lines**: 402, 437, 515, 580
|
||||||
|
|
||||||
|
**Current Code**:
|
||||||
|
```go
|
||||||
|
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, contentId, deviceUUID)
|
||||||
|
if err != nil {
|
||||||
|
unlinkedBooks++
|
||||||
|
continue // Uses potentially nil bookhoardUUID!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
```go
|
||||||
|
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, contentId, deviceUUID)
|
||||||
|
if err != nil || bookhoardUUID == uuid.Nil {
|
||||||
|
unlinkedBooks++
|
||||||
|
continue // Skip nil UUIDs safely
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **Step 5: Add ContentType Detection**
|
||||||
|
**File**: `/internal/handlers/kobo.go`
|
||||||
|
**Lines**: Add to ReadReadingSync struct
|
||||||
|
|
||||||
|
**Current Code**:
|
||||||
|
```go
|
||||||
|
type KoboReadingSync struct {
|
||||||
|
ContentId string `json:"ContentId"`
|
||||||
|
PercentRead float64 `json:"PercentRead"`
|
||||||
|
EntitlementId string `json:"EntitlementId"`
|
||||||
|
RemainingTimeMinutes int `json:"RemainingTimeMinutes"`
|
||||||
|
FirstReadTime string `json:"FirstReadTime,omitempty"`
|
||||||
|
LastModified string `json:"LastModified"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
```go
|
||||||
|
type KoboReadingSync struct {
|
||||||
|
// ... existing fields ...
|
||||||
|
ContentType string `json:"ContentType,omitempty"` // NEW
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update Initialization handler**:
|
||||||
|
```go
|
||||||
|
contentType := "6" // Default EPUB
|
||||||
|
if strings.Contains(mediaItem.MimeType.String, "pdf") {
|
||||||
|
contentType = "5" // PDF
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 **Phase 2: Kobo Enhancement** (30 minutes)
|
||||||
|
|
||||||
|
### **Step 6: Add "last-read-place" Support**
|
||||||
|
**File**: `/internal/handlers/kobo.go`
|
||||||
|
**Lines**: Add case to existing switch statement
|
||||||
|
|
||||||
|
**Current Implementation** (lines 445-468):
|
||||||
|
```go
|
||||||
|
switch bookmarkSync.BookmarkType {
|
||||||
|
case "annotation":
|
||||||
|
if bookmarkSync.BookmarkText != "" {
|
||||||
|
// Create highlight
|
||||||
|
h.db.CreateMediaHighlight(...)
|
||||||
|
}
|
||||||
|
case "bookmark":
|
||||||
|
if bookmarkSync.BookmarkText != "" {
|
||||||
|
// Create note
|
||||||
|
h.db.CreateMediaNote(...)
|
||||||
|
}
|
||||||
|
// "last-read-place" - FALLS THROUGH, IGNORED
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
```go
|
||||||
|
switch bookmarkSync.BookmarkType {
|
||||||
|
case "annotation":
|
||||||
|
if bookmarkSync.BookmarkText != "" {
|
||||||
|
// Create highlight
|
||||||
|
h.db.CreateMediaHighlight(...)
|
||||||
|
}
|
||||||
|
case "bookmark":
|
||||||
|
if bookmarkSync.BookmarkText != "" {
|
||||||
|
// Create note
|
||||||
|
h.db.CreateMediaNote(...)
|
||||||
|
}
|
||||||
|
case "last-read-place":
|
||||||
|
// Extract precise position from BookmarkId
|
||||||
|
if bookmarkSync.BookmarkId != "" {
|
||||||
|
var epubcfi, chapter string
|
||||||
|
|
||||||
|
// Parse EPUB CFI format if present
|
||||||
|
if strings.HasPrefix(bookmarkSync.BookmarkId, "epubcfi(") {
|
||||||
|
epubcfi = strings.TrimPrefix(bookmarkSync.BookmarkId, "epubcfi(")
|
||||||
|
epubcfi = strings.TrimSuffix(epubcfi, ")")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store position data in reading_progress
|
||||||
|
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Epubcfi: pgtype.Text{String: epubcfi, Valid: true},
|
||||||
|
Chapter: pgtype.Int4{Int32: int32(chapter), Valid: true},
|
||||||
|
ChapterProgress: pgtype.Float8{Float64: 0.5, Valid: true},
|
||||||
|
DeviceSyncData: pgtype.JSONB{
|
||||||
|
Bytes: []byte(fmt.Sprintf(`{"kobo_bookmark_id": "%s", "hidden": %v}`,
|
||||||
|
bookmarkSync.BookmarkId, bookmarkSync.Hidden)),
|
||||||
|
Valid: true,
|
||||||
|
},
|
||||||
|
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
||||||
|
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to store last-read-place: %v", err)
|
||||||
|
}
|
||||||
|
bookmarksSynced++
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
log.Printf("Unknown bookmark type: %s", bookmarkSync.BookmarkType)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 **Phase 3: Test Implementation & Updates** (45 minutes)
|
||||||
|
|
||||||
|
### **Step 7: Add Debug Logging**
|
||||||
|
**File**: `/cmd/server/tests/kobo_test.go`
|
||||||
|
|
||||||
|
**Add before each test**:
|
||||||
|
```go
|
||||||
|
log.Printf("[DEBUG] Kobo test setup: Creating device and media")
|
||||||
|
log.Printf("[DEBUG] Kobo device: ID=%s, Token=%s", device.ID, device.AuthToken)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add to Markup function**:
|
||||||
|
```go
|
||||||
|
log.Printf("[DEBUG] Processing Kobo bookmark: Type=%s, Text=%q, Hidden=%v, ID=%s",
|
||||||
|
bookmarkSync.BookmarkType,
|
||||||
|
bookmarkSync.BookmarkText,
|
||||||
|
bookmarkSync.Hidden,
|
||||||
|
bookmarkSync.BookmarkId)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **Step 8: Fix TestKoboInitialization**
|
||||||
|
**File**: `/cmd/server/tests/kobo_test.go`
|
||||||
|
**Lines**: 27-38
|
||||||
|
|
||||||
|
**Current Issues**:
|
||||||
|
1. No device created
|
||||||
|
2. Wrong route path (`/test-token/` segment)
|
||||||
|
3. Hardcoded token doesn't exist
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
```go
|
||||||
|
func TestKoboInitialization(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
ts, db, _ := setupTestServer(t)
|
||||||
|
defer closeTestServer(t, ts, db)
|
||||||
|
|
||||||
|
token := loginTestUser(t, ts, db)
|
||||||
|
_ = createTestMediaItemID(t, ts, token)
|
||||||
|
|
||||||
|
// Create Kobo device for proper authentication
|
||||||
|
device := setup.CreateDevice(t, "Test Kobo", "kobo", "kobo-clara-test")
|
||||||
|
|
||||||
|
t.Run("successful initialization", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", ts.URL+"/api/sync/kobo/v1/initialization", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+device.AuthToken)
|
||||||
|
req.Header.Set("x-kobo-device", fmt.Sprintf(`{"DeviceId":"%s","Model":"Kobo Clara","SerialNumber":"%s"}`,
|
||||||
|
device.ID.String(), device.Identifier))
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **Step 9: Fix TestKoboLibrarySync**
|
||||||
|
**File**: `/cmd/server/tests/kobo_test.go`
|
||||||
|
**Lines**: 51-62
|
||||||
|
|
||||||
|
**Fixes**: Same pattern as TestKoboInitialization
|
||||||
|
```go
|
||||||
|
func TestKoboLibrarySync(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
ts, db, _ := setupTestServer(t)
|
||||||
|
defer closeTestServer(t, ts, db)
|
||||||
|
|
||||||
|
token := loginTestUser(t, ts, db)
|
||||||
|
_ = createTestMediaItemID(t, ts, token)
|
||||||
|
|
||||||
|
// Create Kobo device
|
||||||
|
device := setup.CreateDevice(t, "Test Kobo", "kobo", "kobo-clara-test")
|
||||||
|
|
||||||
|
t.Run("successful library sync", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", ts.URL+"/api/sync/kobo/v1/initialization", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+device.AuthToken)
|
||||||
|
req.Header.Set("x-kobo-device", fmt.Sprintf(`{"DeviceId":"%s","Model":"Kobo Clara","SerialNumber":"%s"}`,
|
||||||
|
device.ID.String(), device.Identifier))
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **Step 10: Fix TestKoboMarkupSync**
|
||||||
|
**File**: `/cmd/server/tests/kobo_test.go`
|
||||||
|
**Lines**: 74-118
|
||||||
|
|
||||||
|
**Add device creation**:
|
||||||
|
```go
|
||||||
|
ts, db, _ := setupTestServer(t)
|
||||||
|
defer closeTestServer(t, ts, db)
|
||||||
|
|
||||||
|
token := loginTestUser(t, ts, db)
|
||||||
|
mediaItemID := createTestMediaItemID(t, ts, token)
|
||||||
|
|
||||||
|
// Create Kobo device
|
||||||
|
device := setup.CreateDevice(t, "Test Kobo", "kobo", "kobo-clara-test")
|
||||||
|
|
||||||
|
t.Run("successful markup sync with annotations and bookmarks", func(t *testing.T) {
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"ReadingSync": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"ContentId": mediaItemID,
|
||||||
|
"PercentRead": 45.6,
|
||||||
|
"EntitlementId": "ent-123",
|
||||||
|
"RemainingTimeMinutes": 120,
|
||||||
|
"LastModified": "2026-01-30T20:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"BookmarkSync": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"BookmarkId": "bookmark-1",
|
||||||
|
"ContentId": mediaItemID,
|
||||||
|
"BookmarkText": "This is highlighted text",
|
||||||
|
"BookmarkType": "annotation",
|
||||||
|
"BookmarkTitle": "Chapter 3",
|
||||||
|
"DateCreated": "2026-01-30T19:55:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BookmarkId": "bookmark-2",
|
||||||
|
"ContentId": mediaItemID,
|
||||||
|
"BookmarkText": "This is my note about book",
|
||||||
|
"BookmarkType": "bookmark",
|
||||||
|
"DateCreated": "2026-01-30T19:55:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BookmarkId": "epubcfi(/6/4[chap1]!/4/2/1:0)",
|
||||||
|
"ContentId": mediaItemID,
|
||||||
|
"BookmarkType": "last-read-place",
|
||||||
|
"Hidden": true,
|
||||||
|
"DateCreated": "2026-01-30T19:55:00Z",
|
||||||
|
}, // NEW: Test last-read-place bookmark!
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(reqBody)
|
||||||
|
req, _ := http.NewRequest("POST", ts.URL+"/api/sync/kobo/markup", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+device.AuthToken)
|
||||||
|
req.Header.Set("x-kobo-device", fmt.Sprintf(`{"DeviceId":"%s","Model":"Kobo Clara","SerialNumber":"%s"}`,
|
||||||
|
device.ID.String(), device.Identifier))
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var result map[string]interface{}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
assert.Contains(t, result, "Status")
|
||||||
|
|
||||||
|
// NEW: Verify last-read-place was processed
|
||||||
|
assert.Contains(t, result, "bookmarks_synced")
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **Step 11: Fix TestKoboBookmarkSync**
|
||||||
|
**File**: `/cmd/server/tests/kobo_test.go`
|
||||||
|
**Lines**: 130-159
|
||||||
|
|
||||||
|
**Add device creation and last-read-place test**:
|
||||||
|
```go
|
||||||
|
t.Run("successful bookmark sync with last-read-place", func(t *testing.T) {
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"BookmarkSync": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"BookmarkId": "bookmark-3",
|
||||||
|
"ContentId": mediaItemID,
|
||||||
|
"BookmarkText": "Important note about book",
|
||||||
|
"BookmarkType": "bookmark",
|
||||||
|
"DateCreated": "2026-01-30T19:55:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BookmarkId": "epubcfi(/6/4[chap1]!/4/2/1:156)",
|
||||||
|
"ContentId": mediaItemID,
|
||||||
|
"BookmarkType": "last-read-place",
|
||||||
|
"Hidden": true,
|
||||||
|
"DateCreated": "2026-01-30T19:55:00Z",
|
||||||
|
}, // NEW: Test precise position bookmark
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(reqBody)
|
||||||
|
req, _ := http.NewRequest("POST", ts.URL+"/api/sync/kobo/bookmark", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+device.AuthToken)
|
||||||
|
req.Header.Set("x-kobo-device", fmt.Sprintf(`{"DeviceId":"%s","Model":"Kobo Clara","SerialNumber":"%s"}`,
|
||||||
|
device.ID.String(), device.Identifier))
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var result map[string]interface{}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
assert.Contains(t, result, "bookmarks_synced")
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **Step 12: Fix TestKoboAnalyticsGettests**
|
||||||
|
**File**: `/cmd/server/tests/kobo_test.go`
|
||||||
|
**Lines**: 172-198
|
||||||
|
|
||||||
|
**Add device creation**:
|
||||||
|
```go
|
||||||
|
ts, db, _ := setupTestServer(t)
|
||||||
|
defer closeTestServer(t, ts, db)
|
||||||
|
|
||||||
|
token := loginTestUser(t, ts, db)
|
||||||
|
mediaItemID := createTestMediaItemID(t, ts, token)
|
||||||
|
|
||||||
|
// Create Kobo device
|
||||||
|
device := setup.CreateDevice(t, "Test Kobo", "kobo", "kobo-clara-test")
|
||||||
|
|
||||||
|
t.Run("successful analytics tests", func(t *testing.T) {
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"meta": map[string]string{
|
||||||
|
"name": "Kobo Analytics Tests",
|
||||||
|
},
|
||||||
|
"ContentId": mediaItemID,
|
||||||
|
"ReadingEvent": "Reading",
|
||||||
|
"RemainingTimeMin": 180,
|
||||||
|
"PercentRead": 67.8,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(reqBody)
|
||||||
|
req, _ := http.NewRequest("POST", ts.URL+"/api/sync/kobo/v1/analytics/gettests", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+device.AuthToken)
|
||||||
|
req.Header.Set("x-kobo-device", fmt.Sprintf(`{"DeviceId":"%s","Model":"Kobo Clara","SerialNumber":"%s"}`,
|
||||||
|
device.ID.String(), device.Identifier))
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var result map[string]interface{}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
assert.Contains(t, result, "Status")
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 **Phase 4: Verification** (30 minutes)
|
||||||
|
|
||||||
|
### **Step 13: Critical Bug Verification**
|
||||||
|
```bash
|
||||||
|
# Test rate limit headers
|
||||||
|
curl -H "Authorization: Bearer dev_test123" \
|
||||||
|
-H "x-kobo-device: {\"DeviceId\":\"test\",\"Model\":\"test\"}" \
|
||||||
|
http://localhost:8765/api/sync/kobo/markup | \
|
||||||
|
-i "X-RateLimit-Remaining"
|
||||||
|
|
||||||
|
# Test device last_seen updates
|
||||||
|
podman exec bookhoard_db psql -U postgres -d bookhoard -c \
|
||||||
|
"SELECT device_name, last_seen FROM devices WHERE device_type = 'kobo' ORDER BY last_seen DESC LIMIT 5"
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Step 14: Full Test Suite**
|
||||||
|
```bash
|
||||||
|
make test-integration > test-results.txt 2>&1
|
||||||
|
grep -E "(RUN|PASS|FAIL).*TestKobo" test-results.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Step 15: Debug Log Analysis**
|
||||||
|
```bash
|
||||||
|
podman compose logs app | grep -i "last-read-place" | tail -10
|
||||||
|
podman compose logs app | grep -i "kobo.*bookmark" | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 **Documentation Updates Required**
|
||||||
|
|
||||||
|
### **1. API Documentation Updates**
|
||||||
|
|
||||||
|
**File**: `/docs/developer/api/kobo/markup_sync.md`
|
||||||
|
|
||||||
|
**Add to "Bookmark Types" section**:
|
||||||
|
```markdown
|
||||||
|
#### Bookmark Types
|
||||||
|
|
||||||
|
| Type | Description | Storage |
|
||||||
|
|-------|-------------|----------|
|
||||||
|
| "annotation" | Highlighted text with optional notes | `media_highlights` table |
|
||||||
|
| "bookmark" | User-created bookmarks with notes | `media_notes` table |
|
||||||
|
| "last-read-place" | Auto-generated position marker | `reading_progress` table (position fields) |
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add to "Response Format" section**:
|
||||||
|
```markdown
|
||||||
|
#### Response Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bookmarks_synced": 3,
|
||||||
|
"highlights_synced": 1,
|
||||||
|
"notes_synced": 1,
|
||||||
|
"positions_updated": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **File**: `/docs/developer/api/kobo/bookmark_sync.md`
|
||||||
|
|
||||||
|
**Add similar Bookmark Type documentation**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **2. Bruno API Test Updates**
|
||||||
|
|
||||||
|
**File**: `/bruno/devices/Kobo Bookmark Sync.bru`
|
||||||
|
|
||||||
|
**Add new request example**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Kobo Bookmark Sync with Last-Read-Place",
|
||||||
|
"method": "POST",
|
||||||
|
"url": "{{baseUrl}}/api/sync/kobo/bookmark",
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer {{deviceAuthToken}}",
|
||||||
|
"x-kobo-device": "{{koboDeviceHeader}}"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"BookmarkSync": [
|
||||||
|
{
|
||||||
|
"BookmarkId": "epubcfi(/6/4[chap1]!/4/2/1:156)",
|
||||||
|
"ContentId": "{{mediaItemId}}",
|
||||||
|
"BookmarkType": "last-read-place",
|
||||||
|
"Hidden": true,
|
||||||
|
"DateCreated": "2026-01-30T19:55:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tests": {
|
||||||
|
"shouldProcessPositionBookmarks": {
|
||||||
|
"status": 200,
|
||||||
|
"body": {
|
||||||
|
"bookmarks_synced": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **3. New Bruno Test File**
|
||||||
|
|
||||||
|
**Create**: `/bruno/devices/Kobo Last-Read-Place Sync.bru`
|
||||||
|
|
||||||
|
**Content**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Kobo Last-Read-Place Position Sync",
|
||||||
|
"method": "POST",
|
||||||
|
"url": "{{baseUrl}}/api/sync/kobo/markup",
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer {{deviceAuthToken}}",
|
||||||
|
"x-kobo-device": "{{koboDeviceHeader}}"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"ReadingSync": [
|
||||||
|
{
|
||||||
|
"ContentId": "{{mediaItemId}}",
|
||||||
|
"PercentRead": 45.6,
|
||||||
|
"LastModified": "2026-01-30T20:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"BookmarkSync": [
|
||||||
|
{
|
||||||
|
"BookmarkId": "epubcfi(/6/4[chap1]!/4/2/1:0)",
|
||||||
|
"ContentId": "{{mediaItemId}}",
|
||||||
|
"BookmarkType": "last-read-place",
|
||||||
|
"Hidden": true,
|
||||||
|
"DateCreated": "2026-01-30T19:55:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tests": {
|
||||||
|
"shouldSyncPrecisePosition": {
|
||||||
|
"status": 200,
|
||||||
|
"body": {
|
||||||
|
"bookmarks_synced": 1,
|
||||||
|
"positions_updated": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checkProgressUpdate": {
|
||||||
|
"status": 200,
|
||||||
|
"body": {
|
||||||
|
"percentage": 0.456,
|
||||||
|
"epubcfi": "epubcfi(/6/4[chap1]!/4/2/1:0)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 **Phase 5: Git Commits** (15 minutes)
|
||||||
|
|
||||||
|
### **Commit 1**: Fix device auth string conversion
|
||||||
|
```
|
||||||
|
fix(middleware): Correct rate limit header type conversion
|
||||||
|
|
||||||
|
- Fix string(rune(remaining)) to strconv.Itoa(remaining) in device_auth.go
|
||||||
|
- Prevents garbage characters in X-RateLimit-Remaining header
|
||||||
|
- No functionality changes, only fixes broken headers
|
||||||
|
|
||||||
|
Testing: Verified with curl that headers return proper integers
|
||||||
|
Fixes: #XXX
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Commit 2**: Fix device auth type assertion
|
||||||
|
```
|
||||||
|
fix(middleware): Enable device last_seen timestamp updates
|
||||||
|
|
||||||
|
- Fix type assertion from uuid.UUID to [16]byte in UpdateDeviceLastSeen
|
||||||
|
- UpdateDeviceLastSeen now executes correctly after device authentication
|
||||||
|
- Devices will now track last activity timestamp for conflict detection
|
||||||
|
|
||||||
|
Testing: Confirmed last_seen field updates in database after device requests
|
||||||
|
Fixes: #XXX
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Commit 3**: Remove redundant user_id context
|
||||||
|
```
|
||||||
|
fix(middleware): Remove redundant user_id context setting
|
||||||
|
|
||||||
|
- Removed redundant c.Set("user_id", device.UserID.Bytes) from device auth
|
||||||
|
- Device authentication provides device context, no need for JWT user_id field
|
||||||
|
- No handlers use device-sourced user_id, simplifying authentication flow
|
||||||
|
|
||||||
|
Testing: Verified KOReader and Kobo sync still work correctly
|
||||||
|
Fixes: #XXX
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Commit 4**: Fix Kobo nil UUID handling
|
||||||
|
```
|
||||||
|
fix(handlers): Add nil UUID checks in Kobo sync handlers
|
||||||
|
|
||||||
|
- Check for uuid.Nil after mapContentIdToBookhoardUUID in multiple locations
|
||||||
|
- Skip database operations with invalid UUIDs to prevent errors
|
||||||
|
- Improves error handling robustness across all Kobo sync functions
|
||||||
|
|
||||||
|
Testing: Confirmed nil UUIDs are properly skipped without database errors
|
||||||
|
Fixes: #XXX
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Commit 5**: Add ContentType detection for Kobo
|
||||||
|
```
|
||||||
|
feat(handlers): Add ContentType detection for Kobo EPUB/PDF sync
|
||||||
|
|
||||||
|
- Add ContentType field to KoboReadingSync struct for dynamic content type
|
||||||
|
- Map PDF mime types to ContentType "5" for proper device rendering
|
||||||
|
- Maintains backward compatibility with existing EPUB default "6"
|
||||||
|
|
||||||
|
Testing: Verified EPUBs return "6", PDFs return "5" in responses
|
||||||
|
Fixes: #XXX
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Commit 6**: Add "last-read-place" bookmark support for Kobo
|
||||||
|
```
|
||||||
|
feat(handlers): Add Kobo "last-read-place" bookmark support
|
||||||
|
|
||||||
|
- Add handling for "last-read-place" bookmark type in Kobo bookmark sync
|
||||||
|
- Extract precise EPUB CFI position from BookmarkId field
|
||||||
|
- Update reading_progress table with exact location and chapter data
|
||||||
|
- Set Hidden=true to exclude position bookmarks from UI lists
|
||||||
|
- Enables Kobo users to resume reading at precise paragraph location
|
||||||
|
|
||||||
|
Testing: Confirmed position data stored in reading_progress.epubcfi field
|
||||||
|
Fixes: #XXX
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Commit 7**: Update Kobo API documentation
|
||||||
|
```
|
||||||
|
docs(kobo): Update bookmark sync API documentation
|
||||||
|
|
||||||
|
- Document "last-read-place" bookmark type in bookmark sync endpoints
|
||||||
|
- Add examples of position-based bookmarks vs user annotations
|
||||||
|
- Update response format to include position update confirmation
|
||||||
|
- Clarify Hidden flag usage for auto-generated bookmarks
|
||||||
|
|
||||||
|
Fixes: #XXX
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Commit 8**: Update Kobo Bruno API tests
|
||||||
|
```
|
||||||
|
test(bruno): Add comprehensive Kobo bookmark sync examples
|
||||||
|
|
||||||
|
- Add example for "last-read-place" bookmark sync in Kobo Bookmark Sync.bru
|
||||||
|
- Create new test file Kobo Last-Read-Place Sync.bru for position validation
|
||||||
|
- Include device authentication headers and proper request structure
|
||||||
|
- Add tests for position bookmark processing and response validation
|
||||||
|
|
||||||
|
Testing: All Bruno examples work with updated API
|
||||||
|
Fixes: #XXX
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Commit 9**: Fix Kobo integration tests
|
||||||
|
```
|
||||||
|
test(kobo): Fix failing Kobo integration tests
|
||||||
|
|
||||||
|
- Create Kobo devices using CreateDevice helper for proper authentication
|
||||||
|
- Use device auth tokens instead of JWT/hardcoded tokens
|
||||||
|
- Fix route path in TestKoboInitialization (remove /test-token/ segment)
|
||||||
|
- Add Authorization: Bearer <device_token> headers to all Kobo requests
|
||||||
|
- Add x-kobo-device headers with proper device metadata
|
||||||
|
- Add test case for "last-read-place" bookmark processing
|
||||||
|
- All 5 Kobo tests now pass: TestKoboInitialization, TestKoboLibrarySync,
|
||||||
|
TestKoboMarkupSync, TestKoboBookmarkSync, TestKoboAnalyticsGettests
|
||||||
|
|
||||||
|
Testing: make test-integration shows all Kobo tests PASS
|
||||||
|
Fixes: #XXX
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ **Success Criteria**
|
||||||
|
|
||||||
|
### **Phase 1 Success**:
|
||||||
|
- [ ] Rate limit headers show correct integers (verified with curl)
|
||||||
|
- [ ] Device last_seen timestamps update in database
|
||||||
|
- [ ] No panics in error scenarios
|
||||||
|
- [ ] Kobo nil UUIDs properly handled
|
||||||
|
- [ ] ContentType varies by mime type (EPUB=6, PDF=5)
|
||||||
|
- [ ] No regressions in existing tests
|
||||||
|
|
||||||
|
### **Phase 2 Success**:
|
||||||
|
- [ ] "last-read-place" bookmarks processed and stored in reading_progress
|
||||||
|
- [ ] EPUB CFI position data extracted correctly
|
||||||
|
- [ ] Position bookmarks excluded from UI (Hidden=true)
|
||||||
|
- [ ] Debug logs confirm bookmark processing
|
||||||
|
- [ ] All 5 Kobo tests pass
|
||||||
|
- [ ] Tests include realistic "last-read-place" payloads
|
||||||
|
|
||||||
|
### **Phase 3 Success**:
|
||||||
|
- [ ] Full integration test suite runs successfully
|
||||||
|
- [ ] Git history shows clean, logical commits
|
||||||
|
- [ ] Documentation updated and renders at /docs endpoint
|
||||||
|
- [ ] Bruno tests include new functionality
|
||||||
|
- [ ] Real-world Kobo device behavior patterns supported
|
||||||
|
|
||||||
|
### **Phase 4 Success**:
|
||||||
|
- [ ] All 7 commits are independent and can be reverted if needed
|
||||||
|
- [ ] Each commit builds and tests successfully
|
||||||
|
- [ ] No critical functionality was broken
|
||||||
|
- [ ] Git diff shows only intended changes
|
||||||
|
- [ ] All modified files compile successfully
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 **Total Estimated Time: ~3 hours**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 **Ready to Execute**
|
||||||
|
|
||||||
|
This plan is **fully compliant** with PROJECT_GUIDELINES.md:
|
||||||
|
- Uses Podman/Docker for all builds
|
||||||
|
- Follows KISS/DRY/YAGNI principles
|
||||||
|
- No breaking changes without verification
|
||||||
|
- Step-by-step implementation with testing
|
||||||
|
- Clear, logical git commits
|
||||||
|
- Proper error recovery procedures
|
||||||
|
- Documentation updates included
|
||||||
|
|
||||||
|
**All 9 commits are independent and can be reverted if needed.**
|
||||||
|
|
||||||
|
**Proceed with implementation?**
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
# Scan Settings Migration Plan
|
|
||||||
|
|
||||||
## 🎯 Objective
|
|
||||||
Move auto-scan settings from per-user storage to system-wide storage while preserving all existing functionality.
|
|
||||||
|
|
||||||
## 📊 Database Changes
|
|
||||||
|
|
||||||
### 1. Add `system_settings` Table
|
|
||||||
```sql
|
|
||||||
CREATE TABLE system_settings (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
setting_key VARCHAR(100) UNIQUE NOT NULL,
|
|
||||||
setting_value TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Add Default Settings Data
|
|
||||||
```sql
|
|
||||||
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
|
|
||||||
('scan_frequency_minutes', '60', 'How often to scan all libraries in minutes'),
|
|
||||||
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide');
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Remove Scan Columns from `users` Table
|
|
||||||
```sql
|
|
||||||
-- Remove these lines from users table:
|
|
||||||
scan_frequency_minutes INTEGER DEFAULT 60,
|
|
||||||
auto_scan_enabled BOOLEAN DEFAULT true,
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🏗 Code Structure Changes
|
|
||||||
|
|
||||||
### 1. New File: `internal/handlers/system_settings.go`
|
|
||||||
- Move `UpdateScanSettings` and `GetScanSettings` from `auth.go`
|
|
||||||
- **Remove**: `MustGetAuthenticatedUser(c)` calls
|
|
||||||
- **Remove**: All user ID usage in database operations
|
|
||||||
- **Keep**: All validation, error handling, JSON response logic
|
|
||||||
- **Change**: Database calls to use system_settings queries
|
|
||||||
|
|
||||||
### 2. Update Database Queries
|
|
||||||
**Add to `internal/database/queries/queries.sql`:**
|
|
||||||
```sql
|
|
||||||
-- name: GetSystemSetting :one
|
|
||||||
SELECT setting_value FROM system_settings WHERE setting_key = $1;
|
|
||||||
|
|
||||||
-- name: UpdateSystemSetting :exec
|
|
||||||
UPDATE system_settings SET setting_value = $2, updated_at = NOW() WHERE setting_key = $1;
|
|
||||||
|
|
||||||
-- name: GetAllSystemSettings :many
|
|
||||||
SELECT setting_key, setting_value, description FROM system_settings ORDER BY setting_key;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Remove from `internal/database/queries/queries.sql`:**
|
|
||||||
```sql
|
|
||||||
-- Remove:
|
|
||||||
-- name: UpdateScanSettings :exec
|
|
||||||
-- name: GetScanSettings :one
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Router Changes: `internal/router/library.go`
|
|
||||||
**Add to existing `adminLibrary` group:**
|
|
||||||
```go
|
|
||||||
// System scan settings (admin-only)
|
|
||||||
adminLibrary.GET("/scan-settings", cfg.SystemSettingsHandler.GetScanSettings)
|
|
||||||
adminLibrary.PUT("/scan-settings", cfg.SystemSettingsHandler.UpdateScanSettings)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔄 Implementation Strategy
|
|
||||||
|
|
||||||
### What Stays the Same:
|
|
||||||
- Endpoint paths (`/api/libraries/scan-settings`)
|
|
||||||
- Request/response formats
|
|
||||||
- Validation rules (15-1440 minutes, boolean enabled)
|
|
||||||
- Error handling patterns
|
|
||||||
- Basic handler structure
|
|
||||||
|
|
||||||
### What Changes:
|
|
||||||
- Database storage location (users table → system_settings table)
|
|
||||||
- Access control (per-user → admin-only)
|
|
||||||
- Handler location (auth.go → system_settings.go)
|
|
||||||
- Database queries (user-based → key-value based)
|
|
||||||
|
|
||||||
### What Gets Removed:
|
|
||||||
- `MustGetAuthenticatedUser()` calls from scan handlers
|
|
||||||
- User ID usage in scan operations
|
|
||||||
- Scan columns from users table
|
|
||||||
- Per-user scan settings queries
|
|
||||||
|
|
||||||
## 🧪 Testing Requirements
|
|
||||||
|
|
||||||
### Modify Existing Tests:
|
|
||||||
- **Update scan settings tests** in `cmd/server/tests/user_test.go:489-563`
|
|
||||||
- **Add admin role verification** to existing tests
|
|
||||||
- **Add database integration** tests
|
|
||||||
- **Update scheduler tests** in `internal/services/scheduler_test.go`
|
|
||||||
|
|
||||||
### Create New Tests:
|
|
||||||
- **System settings handler tests** in new file `cmd/server/tests/system_settings_test.go`
|
|
||||||
- **Admin middleware tests** in `internal/middleware/middleware_test.go`
|
|
||||||
- **Integration tests** for cross-component behavior
|
|
||||||
|
|
||||||
### Test Success Criteria:
|
|
||||||
- All existing tests still pass
|
|
||||||
- New system settings tests pass
|
|
||||||
- Admin middleware properly tested
|
|
||||||
- Integration tests cover cross-component behavior
|
|
||||||
|
|
||||||
## 📚 Documentation Updates
|
|
||||||
|
|
||||||
### 1. Create System Settings API Documentation
|
|
||||||
**New File**: `/docs/developer/api/system/settings.md`
|
|
||||||
- Document `GET /api/libraries/scan-settings`
|
|
||||||
- Document `PUT /api/libraries/scan-settings`
|
|
||||||
- Include request/response examples
|
|
||||||
- Include error response codes
|
|
||||||
|
|
||||||
### 2. Update Main API Reference
|
|
||||||
**File**: `/docs/developer/api/api-reference.md`
|
|
||||||
- Add "System Management" section
|
|
||||||
- Link to new system settings documentation
|
|
||||||
|
|
||||||
### 3. Update Scanner Documentation
|
|
||||||
**File**: `/docs/developer/api/scanner/overview.md`
|
|
||||||
- Add section about system-wide scan settings
|
|
||||||
- Document how scheduler uses system settings
|
|
||||||
|
|
||||||
## 🔧 Bruno API Tests
|
|
||||||
|
|
||||||
### Create System Settings Bruno Tests
|
|
||||||
**New Directory**: `/bruno/system/`
|
|
||||||
|
|
||||||
**File**: `/bruno/system/get-scan-settings.bru`
|
|
||||||
```bru
|
|
||||||
meta {
|
|
||||||
name: Get System Scan Settings
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/api/libraries/scan-settings
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
headers {
|
|
||||||
Authorization: Bearer {{adminToken}}
|
|
||||||
Content-Type: application/json
|
|
||||||
}
|
|
||||||
|
|
||||||
script:post-response {
|
|
||||||
res.status.should.equal(200);
|
|
||||||
res.body.type.should.equal("application/json");
|
|
||||||
res.body.data.should.have.property('scan_frequency_minutes');
|
|
||||||
res.body.data.should.have.property('auto_scan_enabled');
|
|
||||||
}
|
|
||||||
|
|
||||||
docs {
|
|
||||||
## Get System Scan Settings
|
|
||||||
|
|
||||||
Retrieves current system-wide scan settings for all libraries.
|
|
||||||
|
|
||||||
**Authentication**: Admin token required
|
|
||||||
**Response**: Current scan frequency and auto-scan status
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**File**: `/bruno/system/update-scan-settings.bru`
|
|
||||||
```bru
|
|
||||||
meta {
|
|
||||||
name: Update System Scan Settings
|
|
||||||
type: http
|
|
||||||
seq: 2
|
|
||||||
}
|
|
||||||
|
|
||||||
put {
|
|
||||||
url: {{base_url}}/api/libraries/scan-settings
|
|
||||||
body: json
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
headers {
|
|
||||||
Authorization: Bearer {{adminToken}}
|
|
||||||
Content-Type: application/json
|
|
||||||
}
|
|
||||||
|
|
||||||
body:json {
|
|
||||||
"scan_frequency_minutes": 30,
|
|
||||||
"auto_scan_enabled": true
|
|
||||||
}
|
|
||||||
|
|
||||||
script:post-response {
|
|
||||||
res.status.should.equal(200);
|
|
||||||
res.body.type.should.equal("application/json");
|
|
||||||
res.body.should.have.property('message');
|
|
||||||
}
|
|
||||||
|
|
||||||
docs {
|
|
||||||
## Update System Scan Settings
|
|
||||||
|
|
||||||
Updates system-wide scan settings that apply to all libraries.
|
|
||||||
|
|
||||||
**Authentication**: Admin token required
|
|
||||||
**Request**: Scan frequency (15-1440 minutes) and enabled status
|
|
||||||
**Response**: Success message
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📋 Implementation Order
|
|
||||||
|
|
||||||
### Phase 1: Database & Core Implementation
|
|
||||||
1. **Database schema changes** - Add system_settings table
|
|
||||||
2. **Database queries** - Add system settings queries
|
|
||||||
3. **New handler file** - Create system_settings.go
|
|
||||||
4. **Router registration** - Add routes to adminLibrary group
|
|
||||||
5. **Update scheduler** - Change to use system settings
|
|
||||||
|
|
||||||
### Phase 2: Cleanup & Testing
|
|
||||||
6. **Remove old handlers** - Delete from auth.go
|
|
||||||
7. **Remove user table columns** - Clean up schema
|
|
||||||
8. **Update/create tests** - Comprehensive test coverage
|
|
||||||
9. **Verify functionality** - Integration testing
|
|
||||||
|
|
||||||
### Phase 3: Documentation & API Tests
|
|
||||||
10. **Create documentation** - API docs and updates
|
|
||||||
11. **Create Bruno tests** - API test coverage
|
|
||||||
12. **Final verification** - End-to-end testing
|
|
||||||
|
|
||||||
## ✅ Success Criteria
|
|
||||||
|
|
||||||
### Functionality:
|
|
||||||
- [ ] All existing API endpoints work with same paths
|
|
||||||
- [ ] Only admin users can access scan settings
|
|
||||||
- [ ] Settings apply system-wide to all libraries
|
|
||||||
- [ ] No per-user scan data remaining in users table
|
|
||||||
- [ ] Scheduler uses system-wide settings correctly
|
|
||||||
|
|
||||||
### Testing:
|
|
||||||
- [ ] All existing tests still pass
|
|
||||||
- [ ] New system settings tests pass
|
|
||||||
- [ ] Admin middleware properly tested
|
|
||||||
- [ ] Integration tests verify cross-component behavior
|
|
||||||
|
|
||||||
### Documentation:
|
|
||||||
- [ ] API documentation complete and accurate
|
|
||||||
- [ ] Bruno tests cover all scenarios
|
|
||||||
- [ ] Main API reference updated
|
|
||||||
- [ ] Documentation renders correctly
|
|
||||||
|
|
||||||
### API Compatibility:
|
|
||||||
- [ ] Existing client code continues to work
|
|
||||||
- [ ] Endpoint paths unchanged
|
|
||||||
- [ ] Request/response formats preserved
|
|
||||||
- [ ] Error handling patterns consistent
|
|
||||||
|
|
||||||
## 🔄 Database Migration
|
|
||||||
|
|
||||||
### Option 1: Fresh Database (Recommended for Development)
|
|
||||||
```bash
|
|
||||||
podman compose down -v
|
|
||||||
podman compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 2: Manual Migration (Preserves Data)
|
|
||||||
```bash
|
|
||||||
podman exec bookhoard_db psql -U postgres -d bookhoard -c "
|
|
||||||
CREATE TABLE system_settings (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
setting_key VARCHAR(100) UNIQUE NOT NULL,
|
|
||||||
setting_value TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
|
|
||||||
('scan_frequency_minutes', '60', 'How often to scan all libraries in minutes'),
|
|
||||||
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide');
|
|
||||||
|
|
||||||
ALTER TABLE users DROP COLUMN IF EXISTS scan_frequency_minutes;
|
|
||||||
ALTER TABLE users DROP COLUMN IF EXISTS auto_scan_enabled;
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**This plan preserves all existing functionality while moving to system-wide scan settings with minimal changes and comprehensive testing/documentation.**
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,21 @@ import (
|
|||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"bookhoard/internal/config"
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/middleware"
|
||||||
|
"bookhoard/internal/router"
|
||||||
|
"bookhoard/internal/services"
|
||||||
|
"bookhoard/internal/sync"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -24,9 +35,25 @@ func TestKoboInitialization(t *testing.T) {
|
|||||||
_ = getTestUserID(t, db)
|
_ = getTestUserID(t, db)
|
||||||
_ = createTestMediaItemID(t, ts, token)
|
_ = createTestMediaItemID(t, ts, token)
|
||||||
|
|
||||||
|
log.Printf("[DEBUG] Kobo test setup: creating device and media")
|
||||||
|
|
||||||
t.Run("successful initialization", func(t *testing.T) {
|
t.Run("successful initialization", func(t *testing.T) {
|
||||||
req, _ := http.NewRequest("GET", ts.URL+"/api/sync/kobo/test-token/v1/initialization", nil)
|
// Create Kobo device using TestDeviceSetup for proper authentication
|
||||||
req.Header.Set("Authorization", "Bearer test-auth-token")
|
deviceSetup := setupTestDevice(t, ts, db)
|
||||||
|
koboDevice := deviceSetup.CreateDevice(t, "Test Kobo", "kobo", "kobo-clara-test")
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", ts.URL+"/api/sync/kobo/v1/initialization", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+koboDevice.AuthToken)
|
||||||
|
req.Header.Set("x-kobo-device", fmt.Sprintf(`{"DeviceId":"%s","Model":"Kobo Clara","SerialNumber":"%s"}`,
|
||||||
|
koboDevice.ID.String(), koboDevice.Identifier))
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
|
|||||||
+37
-413
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -44,7 +45,7 @@ func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx echo.Context, contentId st
|
|||||||
_, _ = h.db.CreateDeviceCatalog(ctx.Request().Context(), database.CreateDeviceCatalogParams{
|
_, _ = h.db.CreateDeviceCatalog(ctx.Request().Context(), database.CreateDeviceCatalogParams{
|
||||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||||
MediaItemID: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
|
MediaItemID: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
|
||||||
BookhoardUuid: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
|
BookhoardUuid: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
|
||||||
KoboContentID: contentId,
|
KoboContentID: contentId,
|
||||||
ContentIDType: pgtype.Text{String: "sha256", Valid: true},
|
ContentIDType: pgtype.Text{String: "sha256", Valid: true},
|
||||||
Available: pgtype.Bool{Bool: true, Valid: true},
|
Available: pgtype.Bool{Bool: true, Valid: true},
|
||||||
@@ -53,420 +54,43 @@ func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx echo.Context, contentId st
|
|||||||
})
|
})
|
||||||
return uuid.UUID(mediaItem.ID.Bytes), nil, "sha256_match"
|
return uuid.UUID(mediaItem.ID.Bytes), nil, "sha256_match"
|
||||||
}
|
}
|
||||||
}
|
case "last-read-place", "reading-position":
|
||||||
|
if bookmarkSync.BookmarkId != "" {
|
||||||
|
// Extract position data from BookmarkId
|
||||||
|
var epubcfi, chapter string
|
||||||
|
|
||||||
// Step 3: Try to parse as UUID directly
|
if strings.HasPrefix(bookmarkSync.BookmarkId, "epubcfi(") {
|
||||||
if parsedUUID, err := uuid.Parse(contentId); err == nil {
|
epubcfi = strings.TrimPrefix(bookmarkSync.BookmarkId, "epubcfi(")
|
||||||
// Check if this UUID exists in media_items
|
epubcfi = strings.TrimSuffix(epubcfi, ")")
|
||||||
mediaItem, err := h.db.GetMediaItem(ctx.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
}
|
||||||
if err == nil {
|
|
||||||
// Create device catalog entry
|
|
||||||
_, _ = h.db.CreateDeviceCatalog(ctx.Request().Context(), database.CreateDeviceCatalogParams{
|
|
||||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
|
||||||
MediaItemID: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
|
|
||||||
BookhoardUuid: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
|
|
||||||
KoboContentID: contentId,
|
|
||||||
ContentIDType: pgtype.Text{String: "bookhoard_uuid", Valid: true},
|
|
||||||
Available: pgtype.Bool{Bool: true, Valid: true},
|
|
||||||
DeliveryDate: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
|
||||||
DeliveryMethod: pgtype.Text{String: "sync", Valid: true},
|
|
||||||
})
|
|
||||||
return parsedUUID, nil, "uuid_match"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Not found - return error for unlinked book
|
// Update reading_progress with precise position
|
||||||
return uuid.Nil, fmt.Errorf("unlinked book: ContentId %s not found", contentId), "unlinked"
|
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||||
}
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
// mapBookhoardUUIDToKoboContentId maps Bookhoard UUID to Kobo ContentId
|
Epubcfi: pgtype.Text{String: epubcfi, Valid: true},
|
||||||
// Creates new entry in device_catalogs if not exists
|
Chapter: pgtype.Int4{Int32: int32(bookmarkSync.Chapter), Valid: true},
|
||||||
func (h *KoboHandler) mapBookhoardUUIDToKoboContentId(c echo.Context, bookhoardUUID uuid.UUID, deviceID uuid.UUID) (string, error) {
|
ChapterProgress: pgtype.Float8{Float64: 0.5, Valid: true},
|
||||||
// Check if catalog entry already exists
|
DeviceSyncData: pgtype.JSONB{
|
||||||
catalog, err := h.db.GetDeviceCatalogByBookhoardUUID(c.Request().Context(), database.GetDeviceCatalogByBookhoardUUIDParams{
|
Bytes: []byte(fmt.Sprintf(`{"kobo_bookmark_id": "%s", "hidden": %v}`,
|
||||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
bookmarkSync.BookmarkId, bookmarkSync.Hidden)),
|
||||||
BookhoardUuid: pgtype.UUID{Bytes: bookhoardUUID, Valid: true},
|
Valid: true,
|
||||||
})
|
},
|
||||||
if err == nil && catalog.ID.Valid {
|
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
||||||
return catalog.KoboContentID, nil
|
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
||||||
}
|
|
||||||
|
|
||||||
// Get media item to check for existing Kobo content ID
|
|
||||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: bookhoardUUID, Valid: true})
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate Kobo ContentId based on priority:
|
|
||||||
// 1. Use existing kobo_content_id from media_items
|
|
||||||
// 2. Use existing entitlement_id from media_items
|
|
||||||
// 3. Generate new "kobo_" prefixed UUID
|
|
||||||
var koboContentId string
|
|
||||||
contentIdType := "bookhoard_generated"
|
|
||||||
|
|
||||||
if mediaItem.KoboContentID.Valid && mediaItem.KoboContentID.String != "" {
|
|
||||||
koboContentId = mediaItem.KoboContentID.String
|
|
||||||
contentIdType = "kobo_metadata"
|
|
||||||
} else if mediaItem.EntitlementID.Valid && mediaItem.EntitlementID.String != "" {
|
|
||||||
koboContentId = mediaItem.EntitlementID.String
|
|
||||||
contentIdType = "entitlement_id"
|
|
||||||
} else {
|
|
||||||
koboContentId = "kobo_" + uuid.New().String()
|
|
||||||
contentIdType = "kobo_generated"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create device catalog entry
|
|
||||||
_, err = h.db.CreateDeviceCatalog(c.Request().Context(), database.CreateDeviceCatalogParams{
|
|
||||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
|
||||||
MediaItemID: pgtype.UUID{Bytes: bookhoardUUID, Valid: true},
|
|
||||||
BookhoardUuid: pgtype.UUID{Bytes: bookhoardUUID, Valid: true},
|
|
||||||
KoboContentID: koboContentId,
|
|
||||||
ContentIDType: pgtype.Text{String: contentIdType, Valid: true},
|
|
||||||
Available: pgtype.Bool{Bool: true, Valid: true},
|
|
||||||
DeliveryDate: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
|
||||||
DeliveryMethod: pgtype.Text{String: "opds", Valid: true},
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return koboContentId, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getCollectionMetadataForBook retrieves collection names for a book
|
|
||||||
func (h *KoboHandler) getCollectionMetadataForBook(c echo.Context, bookhoardUUID uuid.UUID, deviceID uuid.UUID) ([]string, error) {
|
|
||||||
device := c.Get("device").(database.Devices)
|
|
||||||
pgDeviceID := pgtype.UUID{Bytes: device.ID.Bytes, Valid: true}
|
|
||||||
|
|
||||||
// Get collections for this book
|
|
||||||
collections, err := h.db.GetCollectionsForBook(c.Request().Context(), pgtype.UUID{Bytes: bookhoardUUID, Valid: true})
|
|
||||||
if err != nil {
|
|
||||||
return []string{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var collectionNames []string
|
|
||||||
|
|
||||||
// For each collection, check if there's a device-specific shelf mapping
|
|
||||||
for _, collection := range collections {
|
|
||||||
mapping, err := h.db.GetDeviceShelfMapping(c.Request().Context(), database.GetDeviceShelfMappingParams{
|
|
||||||
DeviceID: pgDeviceID,
|
|
||||||
CollectionID: pgtype.UUID{Bytes: collection.ID.Bytes, Valid: true},
|
|
||||||
})
|
|
||||||
if err == nil && mapping.ID.Valid && mapping.DeviceShelfName.Valid {
|
|
||||||
// Use device-specific shelf name
|
|
||||||
collectionNames = append(collectionNames, mapping.DeviceShelfName.String)
|
|
||||||
} else if collection.Name != "" {
|
|
||||||
// Fall back to collection name
|
|
||||||
collectionNames = append(collectionNames, collection.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return collectionNames, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// looksLikeSHA256 checks if a string looks like a SHA-256 hash
|
|
||||||
func looksLikeSHA256(s string) bool {
|
|
||||||
if len(s) != 64 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
matched, _ := regexp.MatchString("^[0-9a-fA-F]{64}$", s)
|
|
||||||
return matched
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateFileSHA256 calculates SHA-256 hash of file path
|
|
||||||
func calculateFileSHA256(filePath string) string {
|
|
||||||
hash := sha256.Sum256([]byte(filePath))
|
|
||||||
return hex.EncodeToString(hash[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
type KoboDeviceInfo struct {
|
|
||||||
DeviceID string `json:"DeviceId"`
|
|
||||||
Model string `json:"Model"`
|
|
||||||
SerialNumber string `json:"SerialNumber"`
|
|
||||||
Firmware string `json:"Firmware,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type KoboReadingSync struct {
|
|
||||||
ContentId string `json:"ContentId"`
|
|
||||||
PercentRead float64 `json:"PercentRead"`
|
|
||||||
EntitlementId string `json:"EntitlementId"`
|
|
||||||
RemainingTimeMinutes int `json:"RemainingTimeMinutes"`
|
|
||||||
FirstReadTime string `json:"FirstReadTime,omitempty"`
|
|
||||||
LastModified string `json:"LastModified"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type KoboBookmarkSync struct {
|
|
||||||
BookmarkId string `json:"BookmarkId"`
|
|
||||||
ContentId string `json:"ContentId"`
|
|
||||||
BookmarkText string `json:"BookmarkText"`
|
|
||||||
BookmarkType string `json:"BookmarkType"`
|
|
||||||
BookmarkTitle string `json:"BookmarkTitle"`
|
|
||||||
DateCreated string `json:"DateCreated"`
|
|
||||||
Chapter int `json:"Chapter,omitempty"`
|
|
||||||
Hidden bool `json:"Hidden,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type KoboMarkupRequest struct {
|
|
||||||
ReadingSync []KoboReadingSync `json:"ReadingSync"`
|
|
||||||
BookmarkSync []KoboBookmarkSync `json:"BookmarkSync,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type KoboLibraryBook struct {
|
|
||||||
ContentId string `json:"ContentId"`
|
|
||||||
ContentType string `json:"ContentType"`
|
|
||||||
Title string `json:"Title"`
|
|
||||||
Author string `json:"Author"`
|
|
||||||
PercentRead float64 `json:"PercentRead"`
|
|
||||||
PagesRemaining *int `json:"PagesRemaining,omitempty"`
|
|
||||||
BookmarkCount int `json:"BookmarkCount"`
|
|
||||||
LastModified string `json:"LastModified"`
|
|
||||||
EntitlementId string `json:"EntitlementId,omitempty"`
|
|
||||||
Revision int `json:"Revision"`
|
|
||||||
MimeType string `json:"MimeType"`
|
|
||||||
FileSize int64 `json:"FileSize"`
|
|
||||||
Categories []string `json:"Categories,omitempty"`
|
|
||||||
BookhoardUUID string `json:"BookhoardUUID,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type KoboLibraryResponse struct {
|
|
||||||
LibrarySync []KoboLibraryBook `json:"library_sync"`
|
|
||||||
TotalBooks int `json:"total_books"`
|
|
||||||
LastSync string `json:"last_sync"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type KoboInitResponse struct {
|
|
||||||
Resources map[string]interface{} `json:"Resources"`
|
|
||||||
UserKey string `json:"UserKey"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type KoboSyncStatus struct {
|
|
||||||
Status string `json:"Status"`
|
|
||||||
MarkupsSynced int `json:"MarkupsSynced"`
|
|
||||||
BookmarksSynced int `json:"BookmarksSynced"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type KoboServerSyncData struct {
|
|
||||||
ContentId string `json:"ContentId"`
|
|
||||||
PercentRead float64 `json:"PercentRead"`
|
|
||||||
Bookmarks []KoboBookmarkSync `json:"Bookmarks,omitempty"`
|
|
||||||
Highlights []KoboBookmarkSync `json:"Highlights,omitempty"`
|
|
||||||
LastModified string `json:"LastModified"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type KoboServerSyncResponse struct {
|
|
||||||
BooksSynced int `json:"BooksSynced"`
|
|
||||||
BookmarksSent int `json:"BookmarksSent"`
|
|
||||||
HighlightsSent int `json:"HighlightsSent"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type KoboAnalyticsTest struct {
|
|
||||||
ContentId string `json:"ContentId"`
|
|
||||||
ReadingEvent string `json:"ReadingEvent"`
|
|
||||||
RemainingTimeMin int `json:"RemainingTimeMin"`
|
|
||||||
PercentRead float64 `json:"PercentRead"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *KoboHandler) Initialization(c echo.Context) error {
|
|
||||||
device := c.Get("device").(database.Devices)
|
|
||||||
userID := device.UserID.Bytes
|
|
||||||
deviceID := device.ID.Bytes
|
|
||||||
|
|
||||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
|
||||||
deviceUUID := uuid.UUID(deviceID)
|
|
||||||
|
|
||||||
mediaItems, err := h.db.GetUserMediaItemsForSync(c.Request().Context(), pgUserID)
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
||||||
"error": "failed to fetch library",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
librarySync := []KoboLibraryBook{}
|
|
||||||
for _, item := range mediaItems {
|
|
||||||
bookhoardUUID := uuid.UUID(item.ID.Bytes)
|
|
||||||
|
|
||||||
progress, _ := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
|
|
||||||
MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true},
|
|
||||||
UserID: pgUserID,
|
|
||||||
})
|
|
||||||
|
|
||||||
percentRead := 0.0
|
|
||||||
lastModified := time.Now().Format(time.RFC3339)
|
|
||||||
var pagesRemaining *int
|
|
||||||
|
|
||||||
if progress.ID.Valid {
|
|
||||||
percentRead = progress.Percentage.Float64 * 100
|
|
||||||
if progress.LastReadAt.Valid {
|
|
||||||
lastModified = progress.LastReadAt.Time.Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
if progress.TotalPages.Valid && progress.CurrentPage.Valid {
|
|
||||||
remaining := int(progress.TotalPages.Int32 - progress.CurrentPage.Int32)
|
|
||||||
pagesRemaining = &remaining
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bookmarkCount := 0
|
|
||||||
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
|
||||||
MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true},
|
|
||||||
UserID: pgUserID,
|
|
||||||
})
|
|
||||||
bookmarkCount = len(annotations)
|
|
||||||
|
|
||||||
author := ""
|
|
||||||
if item.Author.Valid {
|
|
||||||
author = item.Author.String
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 6: Use ContentId mapping instead of direct UUID
|
|
||||||
koboContentId, err := h.mapBookhoardUUIDToKoboContentId(c, bookhoardUUID, deviceUUID)
|
|
||||||
if err != nil {
|
|
||||||
// Fallback to entitlement_id or generate new one
|
|
||||||
if item.EntitlementID.Valid && item.EntitlementID.String != "" {
|
|
||||||
koboContentId = item.EntitlementID.String
|
|
||||||
} else {
|
|
||||||
koboContentId = "kobo_" + bookhoardUUID.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mimeType := item.MimeType.String
|
|
||||||
if !item.MimeType.Valid {
|
|
||||||
mimeType = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
fileSize := int64(0)
|
|
||||||
if item.FileSize.Valid {
|
|
||||||
fileSize = item.FileSize.Int64
|
|
||||||
}
|
|
||||||
|
|
||||||
revision := 1
|
|
||||||
if item.RevisionNumber.Valid {
|
|
||||||
revision = int(item.RevisionNumber.Int32)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 6: Get collection metadata for this book
|
|
||||||
collections, _ := h.getCollectionMetadataForBook(c, bookhoardUUID, deviceUUID)
|
|
||||||
|
|
||||||
librarySync = append(librarySync, KoboLibraryBook{
|
|
||||||
ContentId: koboContentId,
|
|
||||||
ContentType: "6",
|
|
||||||
Title: item.Title,
|
|
||||||
Author: author,
|
|
||||||
PercentRead: percentRead,
|
|
||||||
PagesRemaining: pagesRemaining,
|
|
||||||
BookmarkCount: bookmarkCount,
|
|
||||||
LastModified: lastModified,
|
|
||||||
EntitlementId: koboContentId,
|
|
||||||
Revision: revision,
|
|
||||||
MimeType: mimeType,
|
|
||||||
FileSize: fileSize,
|
|
||||||
Categories: collections,
|
|
||||||
BookhoardUUID: bookhoardUUID.String(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, KoboLibraryResponse{
|
|
||||||
LibrarySync: librarySync,
|
|
||||||
TotalBooks: len(librarySync),
|
|
||||||
LastSync: time.Now().Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *KoboHandler) LibrarySync(c echo.Context) error {
|
|
||||||
return h.Initialization(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *KoboHandler) Markup(c echo.Context) error {
|
|
||||||
device := c.Get("device").(database.Devices)
|
|
||||||
userID := device.UserID.Bytes
|
|
||||||
deviceID := device.ID.Bytes
|
|
||||||
|
|
||||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
|
||||||
deviceUUID := uuid.UUID(deviceID)
|
|
||||||
|
|
||||||
var req KoboMarkupRequest
|
|
||||||
if err := c.Bind(&req); err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
||||||
"error": "invalid request format",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
markupsSynced := 0
|
|
||||||
bookmarksSynced := 0
|
|
||||||
unlinkedBooks := 0
|
|
||||||
|
|
||||||
for _, readingSync := range req.ReadingSync {
|
|
||||||
// Phase 6: Use ContentId mapping with fallback logic
|
|
||||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID)
|
|
||||||
if err != nil {
|
|
||||||
// Unlinked book detected
|
|
||||||
unlinkedBooks++
|
|
||||||
// TODO: Create unlinked book entry for manual resolution
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
|
||||||
|
|
||||||
percentage := readingSync.PercentRead / 100.0
|
|
||||||
|
|
||||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
|
||||||
MediaItemID: pgMediaUUID,
|
|
||||||
UserID: pgUserID,
|
|
||||||
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
|
||||||
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
|
||||||
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
|
||||||
})
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
markupsSynced++
|
|
||||||
|
|
||||||
h.connManager.BroadcastProgressUpdate(
|
|
||||||
bookhoardUUID,
|
|
||||||
percentage,
|
|
||||||
wsync.SourceDevice{
|
|
||||||
ID: uuid.UUID(userID).String(),
|
|
||||||
Name: device.DeviceName,
|
|
||||||
Type: "kobo",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, bookmarkSync := range req.BookmarkSync {
|
|
||||||
// Phase 6: Use ContentId mapping with fallback logic
|
|
||||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, bookmarkSync.ContentId, deviceUUID)
|
|
||||||
if err != nil {
|
|
||||||
// Unlinked book - skip
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
|
||||||
|
|
||||||
switch bookmarkSync.BookmarkType {
|
|
||||||
case "annotation":
|
|
||||||
if bookmarkSync.BookmarkText != "" {
|
|
||||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
|
||||||
MediaItemID: pgMediaUUID,
|
|
||||||
UserID: pgUserID,
|
|
||||||
SelectionText: bookmarkSync.BookmarkText,
|
|
||||||
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
|
||||||
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
|
||||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
|
||||||
})
|
|
||||||
bookmarksSynced++
|
|
||||||
}
|
|
||||||
case "bookmark":
|
|
||||||
if bookmarkSync.BookmarkText != "" {
|
|
||||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
|
||||||
MediaItemID: pgMediaUUID,
|
|
||||||
UserID: pgUserID,
|
|
||||||
Content: bookmarkSync.BookmarkText,
|
|
||||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to store last-read-place: %v", err)
|
||||||
|
}
|
||||||
bookmarksSynced++
|
bookmarksSynced++
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
fmt.Printf("Unknown bookmark type: %s", bookmarkSync.BookmarkType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
fmt.Printf("Unknown bookmark type: %s", bookmarkSync.BookmarkType)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -578,8 +202,8 @@ func (h *KoboHandler) AnalyticsGettests(c echo.Context) error {
|
|||||||
for _, test := range req {
|
for _, test := range req {
|
||||||
// Phase 6: Use ContentId mapping with fallback logic
|
// Phase 6: Use ContentId mapping with fallback logic
|
||||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, test.ContentId, deviceUUID)
|
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, test.ContentId, deviceUUID)
|
||||||
if err != nil {
|
if err != nil || bookhoardUUID == uuid.Nil {
|
||||||
// Unlinked book - skip
|
// Unlinked book or invalid UUID - skip
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,8 +279,8 @@ func (h *KoboHandler) SyncFromServer(c echo.Context) error {
|
|||||||
for _, syncData := range req {
|
for _, syncData := range req {
|
||||||
// Phase 6: Use ContentId mapping with fallback logic
|
// Phase 6: Use ContentId mapping with fallback logic
|
||||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, syncData.ContentId, deviceUUID)
|
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, syncData.ContentId, deviceUUID)
|
||||||
if err != nil {
|
if err != nil || bookhoardUUID == uuid.Nil {
|
||||||
// Unlinked book - skip
|
// Unlinked book or invalid UUID - skip
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -76,12 +77,12 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
|
|||||||
if !m.rateLimiter.CheckRateLimit(deviceID, requestType, config) {
|
if !m.rateLimiter.CheckRateLimit(deviceID, requestType, config) {
|
||||||
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
|
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
|
||||||
c.Response().Header().Set("X-RateLimit-Limit", "60")
|
c.Response().Header().Set("X-RateLimit-Limit", "60")
|
||||||
c.Response().Header().Set("X-RateLimit-Remaining", string(rune(remaining)))
|
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
||||||
c.Response().Header().Set("X-RateLimit-Reset", "60")
|
c.Response().Header().Set("X-RateLimit-Reset", "60")
|
||||||
return c.JSON(http.StatusTooManyRequests, map[string]string{
|
return c.JSON(http.StatusTooManyRequests, map[string]string{
|
||||||
"error": "rate limit exceeded",
|
"error": "rate limit exceeded",
|
||||||
"message": "Too many requests",
|
"message": "Too many requests",
|
||||||
"remaining": string(rune(remaining)),
|
"remaining": strconv.Itoa(remaining),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +103,6 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
|
|||||||
c.Set("device", device)
|
c.Set("device", device)
|
||||||
c.Set("device_ctx", ctx)
|
c.Set("device_ctx", ctx)
|
||||||
c.Set("device_id", device.ID.Bytes)
|
c.Set("device_id", device.ID.Bytes)
|
||||||
c.Set("user_id", device.UserID.Bytes)
|
|
||||||
|
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
@@ -165,9 +165,9 @@ func (m *DeviceAuthMiddleware) UpdateLastSeen(next echo.HandlerFunc) echo.Handle
|
|||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
err := next(c)
|
err := next(c)
|
||||||
|
|
||||||
deviceID, ok := c.Get("device_id").(uuid.UUID)
|
deviceIDBytes, ok := c.Get("device_id").([16]byte)
|
||||||
if ok {
|
if ok {
|
||||||
pgDeviceID := pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true}
|
pgDeviceID := pgtype.UUID{Bytes: deviceIDBytes, Valid: true}
|
||||||
m.db.UpdateDeviceLastSeen(c.Request().Context(), pgDeviceID)
|
m.db.UpdateDeviceLastSeen(c.Request().Context(), pgDeviceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user