Compare commits

...

5 Commits

Author SHA1 Message Date
Admin
b0d8c02787 fix: add created field to chapters_idx to fix recentlyUpdatedBooks 400
All checks were successful
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 40s
Release / Docker / backend (push) Successful in 2m46s
Release / Docker / runner (push) Successful in 2m43s
Release / Docker / ui (push) Successful in 2m25s
Release / Gitea Release (push) Successful in 50s
chapters_idx was missing created/updated columns (never defined in the
PocketBase schema), causing PocketBase to return 400 for any query
sorted by -created. recentlyUpdatedBooks() uses this sort.

- Add created date field to chapters_idx schema in pb-init-v3.sh
  (also added via add_field for existing installations)
- Add idx_chapters_idx_created index for sort performance
- Set created timestamp on first insert in upsertChapterIdx so new
  chapters are immediately sortable; existing records retain empty created
  and will sort to the back (acceptable — only affects home page recency)
2026-04-04 23:47:23 +05:00
Admin
5b4c1db931 fix: add watchtower label to runner service so auto-updates work
Without com.centurylinklabs.watchtower.enable=true the homelab watchtower
(running with --label-enable) silently skipped the runner container,
leaving it stuck on v2.5.60 while fixes accumulated on newer tags.
2026-04-04 23:39:19 +05:00
Admin
0c54c59586 fix: guard against font_size=0 collapsing chapter text
All checks were successful
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 38s
Release / Docker / backend (push) Successful in 2m30s
Release / Docker / runner (push) Successful in 2m38s
Release / Docker / ui (push) Successful in 2m1s
Release / Gitea Release (push) Successful in 41s
- Replace ?? with || when reading font_size so 0 falls back to 1.0
  (affects GET /api/settings, layout.server.ts, +layout.svelte)
- Remove the explicit 'body.fontSize !== 0' exception in PUT /api/settings
  validation so 0 is now correctly rejected as an invalid font size
- Add add_index helper + idx_chapters_idx_slug_number declaration to
  scripts/pb-init-v3.sh (idempotent UNIQUE INDEX on chapters_idx)
2026-04-04 23:26:54 +05:00
Admin
0e5eb84097 feat: add SvelteKit proxy route for admin dedup-chapters endpoint
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 6m12s
Release / Docker / runner (push) Successful in 3m8s
Release / Docker / ui (push) Successful in 2m15s
Release / Gitea Release (push) Successful in 47s
2026-04-04 22:34:26 +05:00
Admin
6ef82a1d12 fix: add DeduplicateChapters stub to test mocks to satisfy BookWriter interface
All checks were successful
Release / Test backend (push) Successful in 44s
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 2m46s
Release / Docker / runner (push) Successful in 3m19s
Release / Docker / ui (push) Successful in 3m12s
Release / Gitea Release (push) Successful in 1m22s
2026-04-04 21:17:55 +05:00
11 changed files with 106 additions and 18 deletions

View File

@@ -39,8 +39,9 @@ func (m *mockStore) ReadChapter(_ context.Context, _ string, _ int) (string, err
func (m *mockStore) ListChapters(_ context.Context, _ string) ([]domain.ChapterInfo, error) {
return nil, nil
}
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
func (m *mockStore) DeduplicateChapters(_ context.Context, _ string) (int, error) { return 0, nil }
// RankingStore
func (m *mockStore) WriteRankingItem(_ context.Context, _ domain.RankingItem) error { return nil }
@@ -52,10 +53,10 @@ func (m *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool
}
// AudioStore
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
return nil
}

View File

@@ -89,6 +89,8 @@ func (s *stubStore) WriteChapterRefs(_ context.Context, _ string, _ []domain.Cha
return nil
}
func (s *stubStore) DeduplicateChapters(_ context.Context, _ string) (int, error) { return 0, nil }
func (s *stubStore) ChapterExists(_ context.Context, slug string, ref domain.ChapterRef) bool {
s.mu.Lock()
defer s.mu.Unlock()

View File

@@ -94,6 +94,10 @@ func (s *stubBookWriter) ChapterExists(_ context.Context, _ string, _ domain.Cha
return false
}
func (s *stubBookWriter) DeduplicateChapters(_ context.Context, _ string) (int, error) {
return 0, nil
}
// stubBookReader satisfies bookstore.BookReader — returns a single chapter.
type stubBookReader struct {
text string

View File

@@ -130,7 +130,14 @@ func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.Ch
return err
}
if len(items) == 0 {
postErr := s.pb.post(ctx, "/api/collections/chapters_idx/records", payload, nil)
// Set created timestamp on first insert so recentlyUpdatedBooks can sort by it.
insertPayload := map[string]any{
"slug": slug,
"number": ref.Number,
"title": ref.Title,
"created": time.Now().UTC().Format(time.RFC3339),
}
postErr := s.pb.post(ctx, "/api/collections/chapters_idx/records", insertPayload, nil)
if postErr == nil {
return nil
}

View File

@@ -38,6 +38,8 @@ services:
image: kalekber/libnovel-runner:latest
restart: unless-stopped
stop_grace_period: 135s
labels:
- "com.centurylinklabs.watchtower.enable=true"
depends_on:
- libretranslate
# Pin prod subdomains to the prod server IP to bypass Cloudflare's 100s

View File

@@ -62,6 +62,39 @@ create() {
esac
}
# add_index COLLECTION INDEX_NAME SQL_EXPR
# Fetches current schema, adds index if absent by name, PATCHes collection.
add_index() {
COLL="$1"; INAME="$2"; ISQL="$3"
SCHEMA=$(curl -sf -H "Authorization: Bearer $TOK" "$PB/api/collections/$COLL" 2>/dev/null)
PARSED=$(echo "$SCHEMA" | python3 -c "
import sys, json
d = json.load(sys.stdin)
indexes = d.get('indexes', [])
exists = any('$INAME' in idx for idx in indexes)
print('exists=' + str(exists))
print('id=' + d.get('id', ''))
if not exists:
indexes.append('$ISQL')
print('indexes=' + json.dumps(indexes))
" 2>/dev/null)
if echo "$PARSED" | grep -q "^exists=True"; then
log "index exists (skip): $COLL.$INAME"; return
fi
COLL_ID=$(echo "$PARSED" | grep "^id=" | sed 's/^id=//')
[ -z "$COLL_ID" ] && { log "WARNING: cannot resolve id for $COLL"; return; }
NEW_INDEXES=$(echo "$PARSED" | grep "^indexes=" | sed 's/^indexes=//')
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-X PATCH "$PB/api/collections/$COLL_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOK" \
-d "{\"indexes\":${NEW_INDEXES}}")
case "$STATUS" in
200|201) log "added index: $COLL.$INAME" ;;
*) log "WARNING: add_index $COLL.$INAME returned $STATUS" ;;
esac
}
# add_field COLLECTION FIELD_NAME FIELD_TYPE
# Fetches current schema, appends field if absent, PATCHes collection.
# Requires python3 for safe JSON manipulation.
@@ -116,9 +149,10 @@ create "books" '{
create "chapters_idx" '{
"name":"chapters_idx","type":"base","fields":[
{"name":"slug", "type":"text", "required":true},
{"name":"number","type":"number", "required":true},
{"name":"title", "type":"text"}
{"name":"slug", "type":"text", "required":true},
{"name":"number", "type":"number", "required":true},
{"name":"title", "type":"text"},
{"name":"created", "type":"date"}
]}'
create "ranking" '{
@@ -293,5 +327,12 @@ add_field "app_users" "polar_customer_id" "text"
add_field "app_users" "polar_subscription_id" "text"
add_field "user_library" "shelf" "text"
add_field "user_sessions" "device_fingerprint" "text"
add_field "chapters_idx" "created" "date"
# ── 6. Indexes ────────────────────────────────────────────────────────────────
add_index "chapters_idx" "idx_chapters_idx_slug_number" \
"CREATE UNIQUE INDEX idx_chapters_idx_slug_number ON chapters_idx (slug, number)"
add_index "chapters_idx" "idx_chapters_idx_created" \
"CREATE INDEX idx_chapters_idx_created ON chapters_idx (created)"
log "done"

View File

@@ -1,4 +1,2 @@
/* eslint-disable */
export * from './messages/_index.js'
// enabling auto-import by exposing all messages as m
export * as m from './messages/_index.js'
export * from './messages/_index.js'

View File

@@ -28,7 +28,7 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
theme: row.theme ?? 'amber',
locale: row.locale ?? 'en',
fontFamily: row.font_family ?? 'system',
fontSize: row.font_size ?? 1.0
fontSize: row.font_size || 1.0
};
}
} catch (e) {

View File

@@ -100,7 +100,7 @@
// Always sync theme + font (profile page calls invalidateAll after saving)
currentTheme = data.settings.theme ?? 'amber';
currentFontFamily = data.settings.fontFamily ?? 'system';
currentFontSize = data.settings.fontSize ?? 1.0;
currentFontSize = data.settings.fontSize || 1.0;
// Mark dirty only after the synchronous apply is done so the save
// effect doesn't fire for this initial load.
setTimeout(() => { settingsDirty = true; }, 0);

View File

@@ -0,0 +1,33 @@
/**
* POST /api/admin/dedup-chapters/[slug]
*
* Admin-only proxy to the Go backend's dedup endpoint.
* Removes duplicate chapters_idx records for a book, keeping the latest
* record per chapter number. Returns { slug, deleted }.
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ params, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const { slug } = params;
let res: Response;
try {
res = await backendFetch(`/api/admin/dedup-chapters/${encodeURIComponent(slug)}`, {
method: 'POST'
});
} catch (e) {
log.error('admin/dedup-chapters', 'backend proxy error', { slug, err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -18,7 +18,7 @@ export const GET: RequestHandler = async ({ locals }) => {
theme: settings?.theme ?? 'amber',
locale: settings?.locale ?? 'en',
fontFamily: settings?.font_family ?? 'system',
fontSize: settings?.font_size ?? 1.0
fontSize: settings?.font_size || 1.0
});
} catch (e) {
log.error('settings', 'GET failed', { err: String(e) });
@@ -61,9 +61,9 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
error(400, `Invalid fontFamily — must be one of: ${validFontFamilies.join(', ')}`);
}
// fontSize is optional — if provided (and non-zero) it must be one of the valid steps
// fontSize is optional — if provided it must be one of the valid steps (0 is not valid)
const validFontSizes = [0.9, 1.0, 1.15, 1.3];
if (body.fontSize !== undefined && body.fontSize !== 0 && !validFontSizes.includes(body.fontSize)) {
if (body.fontSize !== undefined && !validFontSizes.includes(body.fontSize)) {
error(400, `Invalid fontSize — must be one of: ${validFontSizes.join(', ')}`);
}