Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad1ad11fb5 | ||
|
|
66dd6c8096 | ||
|
|
1205526422 | ||
|
|
29ac420876 | ||
|
|
5ffa371963 | ||
|
|
0be70f0051 | ||
|
|
f07dfbfe74 | ||
|
|
b4b1deafb8 | ||
|
|
217f411f74 | ||
|
|
4fde2a6d83 | ||
|
|
3d88f0b960 |
@@ -27,7 +27,7 @@ docs: |-
|
||||
- `id` (string): Media item ID
|
||||
|
||||
**Request Body:**
|
||||
- `content` (string): Note content (required, 1-10000 chars)
|
||||
- `content` (string): Note content (required, 1-100000 chars)
|
||||
- `position` (string): Optional position reference (max 100 chars)
|
||||
|
||||
**Response:**
|
||||
|
||||
@@ -28,7 +28,7 @@ docs: |-
|
||||
- `noteId` (string): Note ID
|
||||
|
||||
**Request Body:**
|
||||
- `content` (string): Updated note content (required, 1-10000 chars)
|
||||
- `content` (string): Updated note content (required, 1-100000 chars)
|
||||
- `position` (string): Updated position reference (optional, max 100 chars)
|
||||
|
||||
**Response:**
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestMediaNoteLengthLimits verifies the note content validator cap
|
||||
// (internal/handlers/media.go): content up to 100,000 chars is accepted,
|
||||
// longer content is rejected with 400.
|
||||
func TestMediaNoteLengthLimits(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
token := setup.Token
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
|
||||
postNote := func(content string) int {
|
||||
payload := map[string]interface{}{
|
||||
"content": content,
|
||||
"position": "page:1",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items/"+mediaItemID+"/notes", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
t.Run("50k-char note accepted", func(t *testing.T) {
|
||||
assert.Equal(t, http.StatusCreated, postNote(strings.Repeat("a", 50000)))
|
||||
})
|
||||
|
||||
t.Run("100k-char note accepted at boundary", func(t *testing.T) {
|
||||
assert.Equal(t, http.StatusCreated, postNote(strings.Repeat("b", 100000)))
|
||||
})
|
||||
|
||||
t.Run("over-limit note rejected", func(t *testing.T) {
|
||||
assert.Equal(t, http.StatusBadRequest, postNote(strings.Repeat("c", 100001)))
|
||||
})
|
||||
}
|
||||
@@ -30,7 +30,7 @@ The Bookhoard scanner provides comprehensive library management for ebooks, comi
|
||||
| -------- | ------------------------ |
|
||||
| EPUB | `.epub` |
|
||||
| PDF | `.pdf` |
|
||||
| Kindle | `.mobi`, `.azw`, `.azw3` |
|
||||
| Kindle | `.mobi` |
|
||||
| Text | `.txt`, `.rtf` |
|
||||
| Document | `.doc`, `.docx` |
|
||||
| Other | `.lit`, `.fb2`, `.pdb` |
|
||||
|
||||
@@ -18,7 +18,7 @@ Initiate a one-time scan of a library for ebooks, manga, or comics.
|
||||
|
||||
The scanner automatically detects and processes files based on the library type:
|
||||
|
||||
**Ebooks:** .epub, .pdf, .mobi, .azw, .azw3, .txt, .rtf, .doc, .docx, .lit, .fb2, .pdb
|
||||
**Ebooks:** .epub, .pdf, .mobi, .txt, .rtf, .doc, .docx, .lit, .fb2, .pdb
|
||||
|
||||
**Comics:** .cbz, .cbr, .cb7, .cbt, .pdf
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ The following Calibre metadata is **not** imported (by design):
|
||||
|
||||
Bookhoard imports Calibre metadata for **all file types**:
|
||||
|
||||
- 📚 **Ebooks**: EPUB, MOBI, AZW3, PDF, TXT, etc.
|
||||
- 📚 **Ebooks**: EPUB, MOBI, PDF, TXT, etc.
|
||||
- 🎧 **Audiobooks**: MP3, M4B, M4A, etc.
|
||||
- 📰 **Comics**: CBZ, CBR, CB7, CBT, etc.
|
||||
- 📄 **Documents**: PDF, DOCX, etc.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Android App Setup Guide
|
||||
|
||||
The Bookhoard Android app is the native mobile client: browse your libraries, read EPUBs, PDFs, comics and manga, and sync progress, highlights, bookmarks and notes with the server.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- ✅ A Bookhoard instance running and reachable from your phone's network
|
||||
- ✅ The Bookhoard APK installed on your phone (Android 8.0+ / API 26+)
|
||||
- ✅ Your phone connected to the same network as the server (for a self-hosted LAN setup)
|
||||
|
||||
## Installing the app
|
||||
|
||||
The app is distributed as a sideloaded APK:
|
||||
|
||||
1. Copy the APK to your phone (USB, or any file-sync you trust)
|
||||
2. Tap the APK to install — approve the "install unknown apps" prompt for the app you installed from (file manager, browser, etc.)
|
||||
3. Upgrades install straight over the existing app and keep your data (login, downloads, reading state)
|
||||
|
||||
## First run
|
||||
|
||||
1. **Server URL** — enter your server's address. For a self-hosted LAN setup that is `http://<desktop-LAN-IP>:8765` (plain HTTP is expected here and fully supported; check the server machine's firewall allows port 8765 from your LAN)
|
||||
2. **Log in** with your Bookhoard account
|
||||
3. **Device registration** happens automatically — the app registers itself as a synced device so progress and annotations sync under your account
|
||||
|
||||
## Android 16+: the local-network permission
|
||||
|
||||
On Android 16 and newer, apps need explicit permission to talk to devices on your local network (and to non-HTTPS local addresses in general). **If the permission is missing, the app's logins to a LAN server time out with no visible cause** — the phone silently drops the traffic.
|
||||
|
||||
- The app **asks for the permission by itself** during setup, as soon as you enter a local server address — grant it when prompted
|
||||
- If it was denied (or you missed the prompt): **Settings → Apps → Bookhoard → Permissions → "Access local network devices" → Allow**, then try again
|
||||
- Servers reached over the public internet (HTTPS) are not affected by this permission
|
||||
|
||||
## Troubleshooting login failures
|
||||
|
||||
The login screen prints the underlying error after "Could not reach server: …" — read it to narrow the cause:
|
||||
|
||||
| Error | Meaning | What to check |
|
||||
|---|---|---|
|
||||
| `SocketTimeoutException` | The phone sent nothing that reached the server | On Android 16+ this is most often the **local-network permission** (above). Otherwise: wrong IP, phone on a different network/VLAN, or server down |
|
||||
| `ConnectException` (connection refused/blocked) | The phone reached the machine but nothing answered | Server container down, or a firewall rejecting port 8765 |
|
||||
| `UnknownHostException` | The hostname didn't resolve | Typo in the server URL, or a DNS/name issue (raw IPs avoid this) |
|
||||
|
||||
To verify the server is reachable from the phone at all, open the same URL in the phone's browser — the browser is not subject to the per-app local-network permission, so if the browser works but the app times out on Android 16+, it is the permission.
|
||||
@@ -6,6 +6,12 @@ Welcome to the Bookhoard user documentation. This section contains guides for us
|
||||
|
||||
Learn how to configure your e-reader devices to sync with Bookhoard:
|
||||
|
||||
- **[Android App Guide](devices/android-app.md)** - The native Android client
|
||||
- Installing and upgrading the APK
|
||||
- Connecting to a self-hosted server over LAN
|
||||
- The Android 16+ local-network permission
|
||||
- Troubleshooting login failures
|
||||
|
||||
- **[KOReader Setup Guide](devices/koreader-setup.md)** - Complete guide for KOReader
|
||||
- Installation on Kindle/Kobo/PocketBook
|
||||
- Plugin setup with server-side device approval
|
||||
|
||||
@@ -11285,46 +11285,48 @@ WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND ($4 = '' OR word_similarity($4, COALESCE(mi.series, '')) > 0.3)
|
||||
-- Fuzzy genre filter
|
||||
AND ($5 = '' OR word_similarity($5, COALESCE(mi.genre, '')) > 0.3)
|
||||
-- Fuzzy publisher filter
|
||||
AND ($6 = '' OR word_similarity($6, COALESCE(mi.publisher, '')) > 0.3)
|
||||
-- Fuzzy language filter
|
||||
AND ($6 = '' OR word_similarity($6, COALESCE(mi.language, '')) > 0.3)
|
||||
AND ($7 = '' OR word_similarity($7, COALESCE(mi.language, '')) > 0.3)
|
||||
-- Tags filter (NEW - fuzzy match against tags array)
|
||||
AND ($7 = '' OR EXISTS (
|
||||
AND ($8 = '' OR EXISTS (
|
||||
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
||||
WHERE word_similarity($7, tag) > 0.3
|
||||
WHERE word_similarity($8, tag) > 0.3
|
||||
))
|
||||
-- Year range (exact) - prioritize date_published, fallback to copyright_year
|
||||
AND (
|
||||
$8 = 0 OR
|
||||
EXTRACT(YEAR FROM mi.date_published) >= $8 OR
|
||||
(mi.date_published IS NULL AND mi.copyright_year >= $8)
|
||||
$9 = 0 OR
|
||||
EXTRACT(YEAR FROM mi.date_published) >= $9 OR
|
||||
(mi.date_published IS NULL AND mi.copyright_year >= $9)
|
||||
)
|
||||
AND (
|
||||
$9 = 0 OR
|
||||
EXTRACT(YEAR FROM mi.date_published) <= $9 OR
|
||||
(mi.date_published IS NULL AND mi.copyright_year <= $9)
|
||||
$10 = 0 OR
|
||||
EXTRACT(YEAR FROM mi.date_published) <= $10 OR
|
||||
(mi.date_published IS NULL AND mi.copyright_year <= $10)
|
||||
)
|
||||
-- Boolean (exact)
|
||||
AND (
|
||||
$10::bool IS NULL OR -- Not specified = show all
|
||||
($10::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
||||
($10::bool IS FALSE AND mi.cover_image_path IS NULL)
|
||||
$11::bool IS NULL OR -- Not specified = show all
|
||||
($11::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
||||
($11::bool IS FALSE AND mi.cover_image_path IS NULL)
|
||||
)
|
||||
-- Search query (fuzzy or exact based on quotes)
|
||||
AND (
|
||||
$11 = '' OR
|
||||
$12 = '' OR
|
||||
-- Fuzzy search (default)
|
||||
$12 = false AND (
|
||||
word_similarity($11, mi.title) > 0.3 OR
|
||||
word_similarity($11, COALESCE(mi.author, '')) > 0.3 OR
|
||||
word_similarity($11, COALESCE(mi.series, '')) > 0.3 OR
|
||||
$13 = false AND (
|
||||
word_similarity($12, mi.title) > 0.3 OR
|
||||
word_similarity($12, COALESCE(mi.author, '')) > 0.3 OR
|
||||
word_similarity($12, COALESCE(mi.series, '')) > 0.3 OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
||||
WHERE word_similarity($11, tag) > 0.3
|
||||
WHERE word_similarity($12, tag) > 0.3
|
||||
LIMIT 1
|
||||
) OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM unnest(mi.contributors_search) AS contributor
|
||||
WHERE word_similarity($11, contributor) > 0.3
|
||||
WHERE word_similarity($12, contributor) > 0.3
|
||||
LIMIT 1
|
||||
)
|
||||
) OR
|
||||
@@ -11338,86 +11340,88 @@ WHERE COALESCE(lv.is_visible, true) = true
|
||||
-- sqlc.narg('search_pattern') = ANY(mi.contributors_search)
|
||||
-- )
|
||||
-- Exact search (with quotes) - true exact match, not substring
|
||||
$12 = true AND (
|
||||
mi.title = $11 OR
|
||||
COALESCE(mi.author, '') = $11 OR
|
||||
COALESCE(mi.series, '') = $11 OR
|
||||
$11 = ANY(mi.tags_search) OR
|
||||
$11 = ANY(mi.contributors_search)
|
||||
$13 = true AND (
|
||||
mi.title = $12 OR
|
||||
COALESCE(mi.author, '') = $12 OR
|
||||
COALESCE(mi.series, '') = $12 OR
|
||||
$12 = ANY(mi.tags_search) OR
|
||||
$12 = ANY(mi.contributors_search)
|
||||
)
|
||||
)
|
||||
ORDER BY
|
||||
-- Primary sort: relevance score when searching
|
||||
CASE
|
||||
WHEN $11 != '' THEN
|
||||
WHEN $12 != '' THEN
|
||||
GREATEST(
|
||||
CASE WHEN $12 = false THEN
|
||||
word_similarity($11, mi.title)
|
||||
CASE WHEN $13 = false THEN
|
||||
word_similarity($12, mi.title)
|
||||
ELSE 0 END,
|
||||
CASE WHEN $12 = false THEN
|
||||
word_similarity($11, COALESCE(mi.author, ''))
|
||||
CASE WHEN $13 = false THEN
|
||||
word_similarity($12, COALESCE(mi.author, ''))
|
||||
ELSE 0 END,
|
||||
word_similarity($3, COALESCE(mi.author, '')),
|
||||
word_similarity($5, COALESCE(mi.genre, '')),
|
||||
(SELECT MAX(word_similarity($7, tag))
|
||||
word_similarity($6, COALESCE(mi.publisher, '')),
|
||||
(SELECT MAX(word_similarity($8, tag))
|
||||
FROM unnest(mi.tags_search) AS tag)
|
||||
)
|
||||
ELSE 0
|
||||
END DESC,
|
||||
-- Secondary sort: user-specified sort parameter
|
||||
CASE
|
||||
WHEN $13 = 'title ASC' THEN mi.title
|
||||
WHEN $14 = 'title ASC' THEN mi.title
|
||||
ELSE ''
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN $13 = 'title DESC' THEN mi.title
|
||||
WHEN $14 = 'title DESC' THEN mi.title
|
||||
ELSE ''
|
||||
END DESC,
|
||||
CASE
|
||||
WHEN $13 = 'author ASC' THEN COALESCE(mi.author, '')
|
||||
WHEN $14 = 'author ASC' THEN COALESCE(mi.author, '')
|
||||
ELSE ''
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN $13 = 'author DESC' THEN COALESCE(mi.author, '')
|
||||
WHEN $14 = 'author DESC' THEN COALESCE(mi.author, '')
|
||||
ELSE ''
|
||||
END DESC,
|
||||
CASE
|
||||
WHEN $13 = 'created_at ASC' THEN mi.created_at
|
||||
WHEN $14 = 'created_at ASC' THEN mi.created_at
|
||||
ELSE '1970-01-01'::timestamp
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN $13 = 'created_at DESC' THEN mi.created_at
|
||||
WHEN $14 = 'created_at DESC' THEN mi.created_at
|
||||
ELSE '1970-01-01'::timestamp
|
||||
END DESC,
|
||||
CASE
|
||||
WHEN $13 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
|
||||
WHEN $14 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
|
||||
ELSE ''
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN $13 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
|
||||
WHEN $14 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
|
||||
ELSE ''
|
||||
END DESC,
|
||||
-- Tertiary sort: title (default fallback)
|
||||
mi.title ASC
|
||||
LIMIT $15 OFFSET $14
|
||||
LIMIT $16 OFFSET $15
|
||||
`
|
||||
|
||||
type SearchMediaItemsUnifiedParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
AuthorFilter interface{} `db:"author_filter" json:"author_filter"`
|
||||
SeriesFilter interface{} `db:"series_filter" json:"series_filter"`
|
||||
GenreFilter interface{} `db:"genre_filter" json:"genre_filter"`
|
||||
LanguageFilter interface{} `db:"language_filter" json:"language_filter"`
|
||||
TagsFilter interface{} `db:"tags_filter" json:"tags_filter"`
|
||||
YearMin interface{} `db:"year_min" json:"year_min"`
|
||||
YearMax interface{} `db:"year_max" json:"year_max"`
|
||||
HasCover pgtype.Bool `db:"has_cover" json:"has_cover"`
|
||||
SearchQuery interface{} `db:"search_query" json:"search_query"`
|
||||
IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"`
|
||||
Sort interface{} `db:"sort" json:"sort"`
|
||||
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
AuthorFilter interface{} `db:"author_filter" json:"author_filter"`
|
||||
SeriesFilter interface{} `db:"series_filter" json:"series_filter"`
|
||||
GenreFilter interface{} `db:"genre_filter" json:"genre_filter"`
|
||||
PublisherFilter interface{} `db:"publisher_filter" json:"publisher_filter"`
|
||||
LanguageFilter interface{} `db:"language_filter" json:"language_filter"`
|
||||
TagsFilter interface{} `db:"tags_filter" json:"tags_filter"`
|
||||
YearMin interface{} `db:"year_min" json:"year_min"`
|
||||
YearMax interface{} `db:"year_max" json:"year_max"`
|
||||
HasCover pgtype.Bool `db:"has_cover" json:"has_cover"`
|
||||
SearchQuery interface{} `db:"search_query" json:"search_query"`
|
||||
IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"`
|
||||
Sort interface{} `db:"sort" json:"sort"`
|
||||
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type SearchMediaItemsUnifiedRow struct {
|
||||
@@ -11476,6 +11480,7 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt
|
||||
arg.AuthorFilter,
|
||||
arg.SeriesFilter,
|
||||
arg.GenreFilter,
|
||||
arg.PublisherFilter,
|
||||
arg.LanguageFilter,
|
||||
arg.TagsFilter,
|
||||
arg.YearMin,
|
||||
|
||||
@@ -628,6 +628,8 @@ WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
|
||||
-- Fuzzy genre filter
|
||||
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
||||
-- Fuzzy publisher filter
|
||||
AND (sqlc.narg('publisher_filter') = '' OR word_similarity(sqlc.narg('publisher_filter'), COALESCE(mi.publisher, '')) > 0.3)
|
||||
-- Fuzzy language filter
|
||||
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
||||
-- Tags filter (NEW - fuzzy match against tags array)
|
||||
@@ -702,6 +704,7 @@ ORDER BY
|
||||
ELSE 0 END,
|
||||
word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')),
|
||||
word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')),
|
||||
word_similarity(sqlc.narg('publisher_filter'), COALESCE(mi.publisher, '')),
|
||||
(SELECT MAX(word_similarity(sqlc.narg('tags_filter'), tag))
|
||||
FROM unnest(mi.tags_search) AS tag)
|
||||
)
|
||||
|
||||
@@ -101,6 +101,7 @@ type UserProfile struct {
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Role string `json:"role"`
|
||||
Theme string `json:"theme,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
@@ -485,7 +486,14 @@ window.location.href = '%s';
|
||||
|
||||
// GetProfile handles GET /api/auth/profile
|
||||
func (h *AuthHandler) GetProfile(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
ctxUser := MustGetAuthenticatedUser(c)
|
||||
// The context user is a JWT-claims stub (id/email/username/role) —
|
||||
// it carries no theme. Read the full row: clients (the Android app)
|
||||
// sync their app-chrome theme from this field.
|
||||
user, err := h.db.GetUser(c.Request().Context(), ctxUser.ID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load profile"})
|
||||
}
|
||||
|
||||
firstName := ""
|
||||
if user.FirstName.Valid {
|
||||
@@ -502,6 +510,7 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error {
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
Role: user.Role,
|
||||
Theme: user.Theme.String,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+22
-20
@@ -102,13 +102,13 @@ type UpdateMediaItemRequest struct {
|
||||
|
||||
// CreateMediaNoteRequest represents the request for creating a media note
|
||||
type CreateMediaNoteRequest struct {
|
||||
Content string `json:"content" validate:"required,min=1,max=10000"`
|
||||
Content string `json:"content" validate:"required,min=1,max=100000"`
|
||||
Position string `json:"position"`
|
||||
}
|
||||
|
||||
// UpdateMediaNoteRequest represents the request for updating a media note
|
||||
type UpdateMediaNoteRequest struct {
|
||||
Content string `json:"content" validate:"required,min=1,max=10000"`
|
||||
Content string `json:"content" validate:"required,min=1,max=100000"`
|
||||
Position string `json:"position"`
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ type CreateMediaHighlightRequest struct {
|
||||
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
|
||||
EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"`
|
||||
Color string `json:"color" validate:"omitempty,len=7"`
|
||||
NoteText string `json:"note_text" validate:"max=10000"`
|
||||
NoteText string `json:"note_text" validate:"max=100000"`
|
||||
NoteID string `json:"note_id"`
|
||||
PercentageStart float64 `json:"percentage_start"`
|
||||
PercentageEnd float64 `json:"percentage_end"`
|
||||
@@ -135,7 +135,7 @@ type UpdateMediaHighlightRequest struct {
|
||||
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
|
||||
EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"`
|
||||
Color string `json:"color" validate:"omitempty,len=7"`
|
||||
NoteText string `json:"note_text" validate:"max=10000"`
|
||||
NoteText string `json:"note_text" validate:"max=100000"`
|
||||
NoteID string `json:"note_id"`
|
||||
PercentageStart float64 `json:"percentage_start"`
|
||||
PercentageEnd float64 `json:"percentage_end"`
|
||||
@@ -146,7 +146,7 @@ type UpdateMediaHighlightRequest struct {
|
||||
type CreateMediaBookmarkRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=255"`
|
||||
Position string `json:"position" validate:"max=100"`
|
||||
Notes string `json:"notes" validate:"max=10000"`
|
||||
Notes string `json:"notes" validate:"max=100000"`
|
||||
CfiPosition string `json:"cfi_position" validate:"max=255"`
|
||||
PageNumber int32 `json:"page_number"`
|
||||
ChapterNumber int32 `json:"chapter_number"`
|
||||
@@ -160,7 +160,7 @@ type CreateMediaBookmarkRequest struct {
|
||||
// UpdateMediaBookmarkRequest represents the request for updating a media bookmark
|
||||
type UpdateMediaBookmarkRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=255"`
|
||||
Notes string `json:"notes" validate:"max=10000"`
|
||||
Notes string `json:"notes" validate:"max=100000"`
|
||||
Position string `json:"position" validate:"max=100"`
|
||||
}
|
||||
|
||||
@@ -2133,6 +2133,7 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
authorFilter := c.QueryParam("author_filter")
|
||||
seriesFilter := c.QueryParam("series_filter")
|
||||
genreFilter := c.QueryParam("genre_filter")
|
||||
publisherFilter := c.QueryParam("publisher_filter")
|
||||
tagsFilter := c.QueryParam("tags_filter")
|
||||
languageFilter := c.QueryParam("language_filter")
|
||||
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
|
||||
@@ -2152,20 +2153,21 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
|
||||
// Build search params
|
||||
params := services.SearchParams{
|
||||
UserID: userID.ID,
|
||||
LibraryID: libUUID,
|
||||
SearchQuery: query,
|
||||
AuthorFilter: authorFilter,
|
||||
SeriesFilter: seriesFilter,
|
||||
GenreFilter: genreFilter,
|
||||
TagsFilter: tagsFilter,
|
||||
LanguageFilter: languageFilter,
|
||||
YearMin: yearMin,
|
||||
YearMax: yearMax,
|
||||
HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid},
|
||||
Sort: sortParam,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
UserID: userID.ID,
|
||||
LibraryID: libUUID,
|
||||
SearchQuery: query,
|
||||
AuthorFilter: authorFilter,
|
||||
SeriesFilter: seriesFilter,
|
||||
GenreFilter: genreFilter,
|
||||
PublisherFilter: publisherFilter,
|
||||
TagsFilter: tagsFilter,
|
||||
LanguageFilter: languageFilter,
|
||||
YearMin: yearMin,
|
||||
YearMax: yearMax,
|
||||
HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid},
|
||||
Sort: sortParam,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
c.Logger().Info("SearchMediaItems called",
|
||||
|
||||
+27
-25
@@ -22,20 +22,21 @@ func NewSearchService(db *database.Queries) *SearchService {
|
||||
|
||||
// SearchParams contains parameters for unified search
|
||||
type SearchParams struct {
|
||||
UserID pgtype.UUID
|
||||
LibraryID pgtype.UUID
|
||||
AuthorFilter string
|
||||
SeriesFilter string
|
||||
GenreFilter string
|
||||
TagsFilter string
|
||||
LanguageFilter string
|
||||
YearMin int
|
||||
YearMax int
|
||||
HasCover pgtype.Bool
|
||||
SearchQuery string
|
||||
Sort string
|
||||
Limit int
|
||||
Offset int
|
||||
UserID pgtype.UUID
|
||||
LibraryID pgtype.UUID
|
||||
AuthorFilter string
|
||||
SeriesFilter string
|
||||
GenreFilter string
|
||||
PublisherFilter string
|
||||
TagsFilter string
|
||||
LanguageFilter string
|
||||
YearMin int
|
||||
YearMax int
|
||||
HasCover pgtype.Bool
|
||||
SearchQuery string
|
||||
Sort string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// parseSearchQuery detects quoted strings for exact match search
|
||||
@@ -69,17 +70,18 @@ func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params Sear
|
||||
|
||||
// Build database parameters - only set valid true if filter
|
||||
dbParams := database.SearchMediaItemsUnifiedParams{
|
||||
UserID: params.UserID,
|
||||
AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true},
|
||||
SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true},
|
||||
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
|
||||
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
|
||||
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
|
||||
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
|
||||
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
|
||||
HasCover: params.HasCover,
|
||||
SearchQuery: pgtype.Text{String: searchQuery, Valid: true},
|
||||
IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true},
|
||||
UserID: params.UserID,
|
||||
AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true},
|
||||
SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true},
|
||||
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
|
||||
PublisherFilter: pgtype.Text{String: params.PublisherFilter, Valid: true},
|
||||
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
|
||||
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
|
||||
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
|
||||
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
|
||||
HasCover: params.HasCover,
|
||||
SearchQuery: pgtype.Text{String: searchQuery, Valid: true},
|
||||
IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true},
|
||||
// SearchPattern: pgtype.Text{String: searchPattern, Valid: isExact},
|
||||
Sort: pgtype.Text{String: params.Sort, Valid: true},
|
||||
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
"dev": "npm run build:ts:dev && npm run build:css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#e16530a",
|
||||
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#50f56ee",
|
||||
"alpinejs": "^3.15.8",
|
||||
"chart.js": "^4.5.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
|
||||
+20
-3
@@ -179,10 +179,17 @@ templ Reader(user User, metadata ReaderMetadata) {
|
||||
class="reader-note-input"
|
||||
placeholder="Note…"
|
||||
></textarea>
|
||||
<div
|
||||
class="text-xs mt-0.5 text-right"
|
||||
x-show="selectionPopover.note.length > 0"
|
||||
:style="noteOverLimit(selectionPopover.note) ? 'color: #f87171;' : 'color: var(--text-secondary);'"
|
||||
x-text="noteCountLabel(selectionPopover.note)"
|
||||
></div>
|
||||
<div class="flex gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
class="flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="noteOverLimit(selectionPopover.note)"
|
||||
@click="selectionPopover.mode === 'create' ? createHighlight(selectionPopover.color) : saveHighlightChanges()"
|
||||
x-text="selectionPopover.mode === 'create' ? 'Highlight with note' : 'Save note'"
|
||||
>Save</button>
|
||||
@@ -651,14 +658,24 @@ templ ReaderAnnotationsDrawer() {
|
||||
class="reader-note-input"
|
||||
placeholder="Add a note at the current position…"
|
||||
></textarea>
|
||||
<button @click="addNote(newNoteText); newNoteText = ''" class="w-full py-1.5 mt-1 mb-3 text-sm bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
<div
|
||||
class="text-xs mt-0.5 text-right"
|
||||
x-show="newNoteText.length > 0"
|
||||
:style="noteOverLimit(newNoteText) ? 'color: #f87171;' : 'color: var(--text-secondary);'"
|
||||
x-text="noteCountLabel(newNoteText)"
|
||||
></div>
|
||||
<button
|
||||
@click="addNote(newNoteText); newNoteText = ''"
|
||||
:disabled="noteOverLimit(newNoteText)"
|
||||
class="w-full py-1.5 mt-1 mb-3 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
+ Add Note
|
||||
</button>
|
||||
<div class="space-y-2">
|
||||
<template x-for="note in noteItems" :key="note.id">
|
||||
<div class="reader-hl-row group">
|
||||
<a href="#" @click.prevent="closeDrawers()" class="flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700">
|
||||
<span class="block text-sm" x-text="note.content"></span>
|
||||
<span class="block text-sm truncate" x-text="note.content"></span>
|
||||
<span class="block text-xs mt-0.5 truncate" style="color: var(--text-secondary)" x-text="note.positionLabel"></span>
|
||||
</a>
|
||||
<button
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -446,6 +446,10 @@ document.addEventListener("alpine:init", () => {
|
||||
noteItems: [] as { id: string; content: string; positionLabel: string }[],
|
||||
annotationsTab: "highlights" as string,
|
||||
newNoteText: "",
|
||||
// Mirrors the server-side validator cap on note content
|
||||
// (internal/handlers/media.go). Enforced client-side so oversized
|
||||
// input is caught before a doomed POST.
|
||||
noteMaxLength: 100000 as number,
|
||||
highlightColors: HIGHLIGHT_COLORS,
|
||||
searchOpen: false,
|
||||
searchQuery: "",
|
||||
@@ -1652,6 +1656,13 @@ document.addEventListener("alpine:init", () => {
|
||||
// The note editor writes into p.note live; "Highlight with note"
|
||||
// creates the highlight AND its note in one POST.
|
||||
const note = p.note.trim();
|
||||
if (this.noteOverLimit(note)) {
|
||||
showToast(
|
||||
`Note exceeds the ${this.noteMaxLength.toLocaleString()} character limit (${note.length.toLocaleString()}). Shrink it and try again.`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const pdfAnchor =
|
||||
p.pdfPage >= 0
|
||||
? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects })
|
||||
@@ -1721,6 +1732,13 @@ document.addEventListener("alpine:init", () => {
|
||||
const p = this.selectionPopover;
|
||||
const token = getToken();
|
||||
if (!token || !this.mediaItemId || !p.id) return;
|
||||
if (this.noteOverLimit(p.note)) {
|
||||
showToast(
|
||||
`Note exceeds the ${this.noteMaxLength.toLocaleString()} character limit (${p.note.length.toLocaleString()}). Shrink it and try again.`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const anchor =
|
||||
p.pdfPage >= 0
|
||||
? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects })
|
||||
@@ -1997,9 +2015,22 @@ document.addEventListener("alpine:init", () => {
|
||||
this.closeDrawers();
|
||||
}
|
||||
},
|
||||
noteOverLimit(text: string): boolean {
|
||||
return text.length > this.noteMaxLength;
|
||||
},
|
||||
noteCountLabel(text: string): string {
|
||||
return `${text.length.toLocaleString()}/${this.noteMaxLength.toLocaleString()}`;
|
||||
},
|
||||
async addNote(content: string) {
|
||||
const token = getToken();
|
||||
if (!token || !this.mediaItemId || !content.trim()) return;
|
||||
if (this.noteOverLimit(content)) {
|
||||
showToast(
|
||||
`Note exceeds the ${this.noteMaxLength.toLocaleString()} character limit (${content.length.toLocaleString()}). Shrink it and try again.`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/media-items/${this.mediaItemId}/notes`,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user